Skip to main content

hyperdb_mcp/
server.rs

1// Copyright (c) 2026, Salesforce, Inc. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! MCP server implementation and tool parameter types.
5//!
6//! The [`HyperMcpServer`] is the top-level struct registered with the `rmcp`
7//! framework. It lazily initializes the [`Engine`] on first tool call and
8//! routes each MCP tool invocation to the appropriate ingest / query / export
9//! function.
10//!
11//! Parameter structs derive `JsonSchema` so the MCP `tools/list` response
12//! includes full JSON Schema descriptions for each tool's inputs.
13
14use crate::attach::{self, AttachRegistry, AttachRequest, AttachSource, LOCAL_ALIAS};
15use crate::chart::{render_chart, ChartFormat, ChartOptions, ChartType};
16use crate::engine::{classify_statement, is_read_only_sql, Engine, StatementKind};
17use crate::error::{ErrorCode, McpError};
18use crate::export::{export_to_file, ExportOptions};
19use crate::ingest::{
20    detect_file_format, ingest_csv, ingest_csv_file, ingest_csv_file_async, ingest_json,
21    ingest_json_file, ingest_json_file_async, InferredFileFormat, IngestOptions,
22};
23use crate::ingest_arrow::{
24    ingest_arrow_ipc_file, ingest_arrow_ipc_file_async, ingest_parquet_file,
25    ingest_parquet_file_async,
26};
27use crate::saved_queries::{build_store, SavedQuery, SavedQueryStore};
28use crate::subscriptions::{
29    uris_for_table_change, uris_for_workspace_change, SubscriptionRegistry,
30};
31use base64::Engine as _;
32use rmcp::handler::server::router::prompt::PromptRouter;
33use rmcp::handler::server::router::tool::ToolRouter;
34use rmcp::handler::server::wrapper::Parameters;
35use rmcp::model::{
36    AnnotateAble, CallToolResult, Content, GetPromptRequestParams, GetPromptResult, Implementation,
37    InitializeRequestParams, InitializeResult, ListPromptsResult, ListResourceTemplatesResult,
38    ListResourcesResult, PaginatedRequestParams, PromptMessage, PromptMessageRole, RawResource,
39    RawResourceTemplate, ReadResourceRequestParams, ReadResourceResult, ResourceContents,
40    ServerCapabilities, ServerInfo, SubscribeRequestParams, UnsubscribeRequestParams,
41};
42use rmcp::service::RequestContext;
43use rmcp::{
44    prompt, prompt_handler, prompt_router, tool, tool_handler, tool_router, RoleServer,
45    ServerHandler,
46};
47use schemars::JsonSchema;
48use serde::Deserialize;
49use serde_json::{json, Value};
50use sqlformat::{FormatOptions, Indent, QueryParams as SqlQueryParams};
51use std::fmt::Write as _;
52use std::sync::{Arc, Mutex};
53
54#[expect(
55    unused_imports,
56    reason = "imported for use in doc comments that reference the type path"
57)]
58use rmcp::model::RawTextContent;
59
60/// Number of rows returned by the `hyper://tables/{name}/sample` JSON
61/// resource. Kept small so an MCP client can prefetch every table's sample
62/// into the LLM context without blowing up the prompt budget.
63const TABLE_SAMPLE_ROWS: u64 = 5;
64
65/// Number of rows returned by the `hyper://tables/{name}/csv-sample` CSV
66/// resource. Slightly larger than the JSON sample because CSV is a much
67/// more compact wire format and the extra rows help LLMs see patterns.
68const TABLE_CSV_SAMPLE_ROWS: u64 = 20;
69
70// --- Parameter structs ---
71// Field-level doc comments become JSON Schema `description` fields in the
72// MCP `tools/list` response, so they are written for the LLM caller.
73
74/// Schema override shape shared by `query_data`, `query_file`, `load_data`,
75/// and `load_file`. Documented here once so all four tools can reference it
76/// without duplicating the prose in every field doc.
77///
78/// Pass a JSON object mapping **column name → Hyper type string**, for example:
79///
80/// ```json
81/// { "year": "INT", "population": "BIGINT", "entity": "TEXT" }
82/// ```
83///
84/// Override semantics (applied inside ingest):
85/// * Keys are matched to columns **by name** (case-sensitive). Column ordering
86///   in the JSON object does not need to match the file; the inferred order
87///   from the file is preserved.
88/// * Columns *not* listed in the override keep their inferred type — you only
89///   need to specify the columns you want to correct.
90/// * Types are the Hyper SQL type spellings: `INT`, `BIGINT`, `NUMERIC(38,0)`,
91///   `DOUBLE PRECISION`, `TEXT`, `BOOL`, `DATE`, `TIMESTAMP`.
92/// * If you get a `SchemaMismatch` with suggestion to widen an integer column,
93///   the typical fix is `{ "col": "BIGINT" }` or `{ "col": "NUMERIC(38,0)" }`.
94///
95/// Before ingesting an unfamiliar file, prefer calling `inspect_file` first —
96/// it returns the inferred schema plus per-column min / max / `null_count` so
97/// you can build a minimal, correct override in one shot.
98///
99/// Parameters for the `query_data` one-shot tool.
100#[derive(Debug, Deserialize, JsonSchema)]
101pub struct QueryDataParams {
102    /// JSON array of objects or CSV text.
103    pub data: String,
104    /// SQL query to run against the data. Reference the table by
105    /// `table_name` (default `data`).
106    pub sql: String,
107    /// Data format: `"json"` or `"csv"`. Auto-detected from the first byte
108    /// when omitted (`[`/`{` → JSON, otherwise CSV).
109    pub format: Option<String>,
110    /// Table name exposed to the SQL query (default: `data`).
111    pub table_name: Option<String>,
112    /// Partial schema override keyed by column name: `{"col": "BIGINT", ...}`.
113    /// Only the listed columns are overridden; the rest keep their inferred
114    /// type. See the struct-level docs on `QueryDataParams` and the
115    /// `inspect_file` tool for type choices and diagnostics.
116    pub schema: Option<Value>,
117}
118
119/// Parameters for the `query_file` one-shot tool.
120#[derive(Debug, Deserialize, JsonSchema)]
121pub struct QueryFileParams {
122    /// Absolute path to a CSV, Parquet, or Arrow IPC file.
123    pub path: String,
124    /// SQL query to run. Reference the table by `table_name` (default:
125    /// filename stem).
126    pub sql: String,
127    /// Table name exposed to the SQL query (default: filename stem).
128    pub table_name: Option<String>,
129    /// Partial schema override keyed by column name: `{"col": "BIGINT", ...}`.
130    /// See the docs on `QueryDataParams` for the full spec. Call
131    /// `inspect_file` first if you are unsure of the correct types.
132    pub schema: Option<Value>,
133    /// Optional dot-separated path to extract a nested data array from the
134    /// JSON file. Numeric segments index into arrays (e.g., `content.0`).
135    /// String values encountered during navigation are automatically parsed
136    /// as JSON, handling the common pattern where MCP tool responses contain
137    /// stringified JSON payloads.
138    pub json_extract_path: Option<String>,
139}
140
141/// Parameters for the `load_data` workspace tool.
142#[derive(Debug, Deserialize, JsonSchema)]
143pub struct LoadDataParams {
144    /// Target table name.
145    pub table: String,
146    /// JSON array of objects or CSV text.
147    pub data: String,
148    /// Data format: `"json"` or `"csv"`. Auto-detected when omitted.
149    pub format: Option<String>,
150    /// `"replace"` (default — drops and recreates the table) or
151    /// `"append"` (adds rows to an existing table).
152    pub mode: Option<String>,
153    /// Partial schema override keyed by column name: `{"col": "BIGINT", ...}`.
154    /// See the docs on `QueryDataParams` for the full spec.
155    pub schema: Option<Value>,
156    /// Target database alias. Omit (or pass `"local"`) to write to the
157    /// ephemeral primary. Pass `"persistent"` to write to the durable
158    /// database that survives across sessions. Other values target a
159    /// user-attached database (must be writable).
160    pub database: Option<String>,
161    /// Shorthand for `database: "persistent"`. When true, data is written
162    /// to the persistent database. If both `database` and `persist` are
163    /// set, `database` wins.
164    pub persist: Option<bool>,
165}
166
167/// Parameters for the `load_file` workspace tool.
168#[derive(Debug, Deserialize, JsonSchema)]
169pub struct LoadFileParams {
170    /// Target table name.
171    pub table: String,
172    /// Absolute path to a CSV, Parquet, or Arrow IPC file.
173    pub path: String,
174    /// `"replace"` (default — drops and recreates the table),
175    /// `"append"` (adds rows to an existing table), or `"merge"`
176    /// (upserts rows by `merge_key`; new columns in the incoming file
177    /// are auto-added via `ALTER TABLE ADD COLUMN`).
178    pub mode: Option<String>,
179    /// Partial schema override keyed by column name: `{"col": "BIGINT", ...}`.
180    /// Only the listed columns are overridden; the rest keep their inferred
181    /// type. Call `inspect_file` first if you are unsure — it reports
182    /// min / max / `null_count` per column using the exact same inference this
183    /// tool uses, so the override you build from its output is guaranteed to
184    /// align with the file's actual columns.
185    pub schema: Option<Value>,
186    /// Optional dot-separated path to extract a nested data array from the
187    /// JSON file. Numeric segments index into arrays (e.g., `content.0`).
188    /// String values encountered during navigation are automatically parsed
189    /// as JSON, handling the common pattern where MCP tool responses contain
190    /// stringified JSON payloads.
191    pub json_extract_path: Option<String>,
192    /// When `mode = "merge"`, the column(s) used to match incoming rows to
193    /// existing rows for upsert. Pass a single name (`"job_id"`) or a list
194    /// (`["cell", "job_id"]`). Required for merge; rejected with a clear
195    /// error if set for `replace` or `append`.
196    pub merge_key: Option<MergeKey>,
197    /// Target database alias. Omit (or pass `"local"`) to write to the
198    /// ephemeral primary. Pass `"persistent"` to write to the durable
199    /// database. Other values target a user-attached writable database.
200    pub database: Option<String>,
201    /// Shorthand for `database: "persistent"`. If both `database` and
202    /// `persist` are set, `database` wins.
203    pub persist: Option<bool>,
204}
205
206/// One or many column names. Accepts either a JSON string `"col"` or
207/// a JSON array `["col1", "col2"]` for ergonomics — the tool layer
208/// normalizes to `Vec<String>` before passing into the ingest code.
209///
210/// A custom [`serde::Deserialize`] implementation produces clear
211/// errors for wrong shapes (`null`, numbers, objects) instead of
212/// the default untagged-enum message ("data did not match any
213/// variant of untagged enum MergeKey"), which is opaque from the
214/// MCP-tool-call side.
215#[derive(Debug, JsonSchema)]
216#[schemars(
217    title = "MergeKey",
218    description = "Either a single column name (string) or a list of column names (array of strings)",
219    untagged
220)]
221pub enum MergeKey {
222    Single(String),
223    Multi(Vec<String>),
224}
225
226impl<'de> Deserialize<'de> for MergeKey {
227    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
228    where
229        D: serde::Deserializer<'de>,
230    {
231        use serde::de::Error;
232        let v = serde_json::Value::deserialize(deserializer)?;
233        match v {
234            serde_json::Value::String(s) => Ok(Self::Single(s)),
235            serde_json::Value::Array(arr) => {
236                let mut names = Vec::with_capacity(arr.len());
237                for (i, item) in arr.into_iter().enumerate() {
238                    match item {
239                        serde_json::Value::String(s) => names.push(s),
240                        other => {
241                            return Err(D::Error::custom(format!(
242                                "merge_key array element [{i}] must be a string \
243                                 (column name); got {other}"
244                            )));
245                        }
246                    }
247                }
248                Ok(Self::Multi(names))
249            }
250            other => Err(D::Error::custom(format!(
251                "merge_key must be a column name (string) or list of column names \
252                 (array of strings); got {other}"
253            ))),
254        }
255    }
256}
257
258impl MergeKey {
259    /// Materialize as a non-empty `Vec<String>`, or return `None` for the
260    /// empty case so callers can convert it into an `InvalidArgument`
261    /// error with a context-appropriate message.
262    pub fn into_vec(self) -> Option<Vec<String>> {
263        let v = match self {
264            Self::Single(s) => vec![s],
265            Self::Multi(v) => v,
266        };
267        if v.is_empty() || v.iter().any(String::is_empty) {
268            None
269        } else {
270            Some(v)
271        }
272    }
273}
274
275/// One file entry within a [`LoadFilesParams`] batch. Same shape as
276/// [`LoadFileParams`] minus cross-cutting concerns handled at the batch
277/// level (the batch-level concurrency knob, etc.).
278#[derive(Debug, Deserialize, JsonSchema)]
279pub struct LoadFilesEntry {
280    /// Target table name.
281    pub table: String,
282    /// Absolute path to a CSV, Parquet, Arrow IPC, or JSON file.
283    pub path: String,
284    /// `"replace"` (default), `"append"`, or `"merge"` — see
285    /// [`LoadFileParams::mode`] for semantics.
286    pub mode: Option<String>,
287    /// Partial schema override keyed by column name.
288    pub schema: Option<Value>,
289    /// Optional JSON extract path — see `LoadFileParams::json_extract_path`.
290    pub json_extract_path: Option<String>,
291    /// When `mode = "merge"`, the column(s) to match on for upsert. See
292    /// [`LoadFileParams::merge_key`].
293    pub merge_key: Option<MergeKey>,
294}
295
296/// Parameters for the `load_files` workspace tool.
297#[derive(Debug, Deserialize, JsonSchema)]
298pub struct LoadFilesParams {
299    /// Batch of files to ingest in parallel. Each entry targets its own
300    /// table and runs independently — one entry's failure does not abort
301    /// the others.
302    pub files: Vec<LoadFilesEntry>,
303    /// Maximum number of concurrent ingest tasks. Each task checks out
304    /// its own connection from a pool sized to match. Default:
305    /// `min(files.len(), 8)`. Large parquet ingests are I/O-bound on
306    /// hyperd's side; more connections don't help past a certain point
307    /// and can starve the primary connection.
308    pub concurrency: Option<u32>,
309    /// Target database alias. Omit (or pass `"local"`) to write to the
310    /// ephemeral primary. Pass `"persistent"` to write to the durable
311    /// database. Other values target a user-attached writable database.
312    /// Applies to every entry in the batch — multi-target batches are
313    /// not supported.
314    pub database: Option<String>,
315    /// Shorthand for `database: "persistent"`. If both `database` and
316    /// `persist` are set, `database` wins.
317    pub persist: Option<bool>,
318}
319
320/// Validate the (`mode`, `merge_key`) combination at the tool boundary.
321/// Returns the normalized `Vec<String>` for merge mode (or `None` for
322/// replace/append). Rejects:
323///
324/// - `mode = "merge"` without `merge_key` → `InvalidArgument`.
325/// - `mode = "merge"` with empty / blank-element `merge_key` →
326///   `InvalidArgument`.
327/// - `mode != "merge"` with `merge_key` set → `InvalidArgument`
328///   (catches "I added merge_key but forgot mode" mistakes loudly).
329fn validate_merge_args(
330    mode: &str,
331    merge_key: Option<MergeKey>,
332) -> Result<Option<Vec<String>>, McpError> {
333    match (mode, merge_key) {
334        ("merge", None) => Err(McpError::new(
335            ErrorCode::InvalidArgument,
336            "mode=merge requires merge_key (a column name or list of column names)",
337        )),
338        ("merge", Some(mk)) => mk.into_vec().map(Some).ok_or_else(|| {
339            McpError::new(
340                ErrorCode::InvalidArgument,
341                "merge_key must be a non-empty list of non-empty column names",
342            )
343        }),
344        (_, Some(_)) => Err(McpError::new(
345            ErrorCode::InvalidArgument,
346            "merge_key is only valid with mode=merge",
347        )),
348        (_, None) => Ok(None),
349    }
350}
351
352/// Parameters for the `load_iceberg` workspace tool.
353///
354/// An Iceberg table on disk is a *directory* containing a `metadata/`
355/// subdir and one or more `data/` parquet files — hyperd reads the
356/// metadata JSON to find the right snapshot and then the data files.
357#[derive(Debug, Deserialize, JsonSchema)]
358pub struct LoadIcebergParams {
359    /// Target Hyper table name.
360    pub table: String,
361    /// Absolute path to the Iceberg table root (the directory that
362    /// contains `metadata/` and `data/`).
363    pub path: String,
364    /// `"replace"` (default) or `"append"`.
365    pub mode: Option<String>,
366    /// Optional specific metadata filename to pin a snapshot, e.g.
367    /// `"v2.metadata.json"`. If omitted, hyperd uses the latest.
368    pub metadata_filename: Option<String>,
369    /// Optional snapshot version to read as of.
370    pub version_as_of: Option<i64>,
371}
372
373/// Parameters for the read-only `query` workspace tool.
374#[derive(Debug, Deserialize, JsonSchema)]
375pub struct QueryParams {
376    /// SQL SELECT / WITH / EXPLAIN / SHOW / VALUES statement (read-only)
377    pub sql: String,
378    /// Target database alias for unqualified name resolution. Omit to
379    /// query the ephemeral primary. Pass `"persistent"` to route to the
380    /// durable database, or any user-attached alias.
381    pub database: Option<String>,
382}
383
384/// Parameters for the mutating `execute` workspace tool.
385#[derive(Debug, Deserialize, JsonSchema)]
386pub struct ExecuteParams {
387    /// One or more DDL/DML SQL statements (CREATE, INSERT, UPDATE, DELETE,
388    /// DROP, ALTER, COPY, etc.) to execute as an atomic batch.
389    ///
390    /// Pass a single-element array `["UPDATE …"]` for one statement, or
391    /// multiple elements for an atomic upsert / multi-table mutation.
392    /// Multi-element batches run inside a transaction — every statement
393    /// commits together or all roll back. Each element must be exactly
394    /// one statement (no embedded `;`-separated multi-statements).
395    ///
396    /// Restrictions enforced before any SQL hits the server:
397    /// - The array must be non-empty and no element may be empty/whitespace.
398    /// - No element may be read-only (use `query` for SELECT/WITH/EXPLAIN).
399    /// - DDL and DML cannot be mixed in one batch (Hyper aborts the
400    ///   transaction with SQLSTATE 0A000).
401    /// - Multi-element all-DDL batches are rejected because Hyper
402    ///   auto-commits CREATE/DROP/ALTER even inside a transaction —
403    ///   issue each DDL in its own `execute` call.
404    pub sql: Vec<String>,
405    /// Target database alias for unqualified name resolution. Omit to
406    /// run against the ephemeral primary. Pass `"persistent"` to write
407    /// to the durable database (or a writable user-attached alias).
408    pub database: Option<String>,
409}
410
411/// Parameters for the `sample` convenience tool.
412#[derive(Debug, Deserialize, JsonSchema)]
413pub struct SampleParams {
414    /// Table name to sample from
415    pub table: String,
416    /// Number of rows to return (default: 5, max: 100)
417    pub n: Option<u64>,
418    /// Target database alias. Omit to sample from the ephemeral primary;
419    /// pass `"persistent"` or a user-attached alias to sample from there.
420    pub database: Option<String>,
421}
422
423/// Parameters for the `describe` tool. Both fields are optional to preserve
424/// backward compatibility with callers that invoke `describe` with no args
425/// to get the full workspace listing.
426#[derive(Debug, Default, Deserialize, JsonSchema)]
427pub struct DescribeParams {
428    /// If set, return the schema and row count for just this table. Omit to
429    /// list every public table in the workspace.
430    pub table: Option<String>,
431    /// Target database alias. Omit to describe tables in the ephemeral
432    /// primary; pass `"persistent"` or a user-attached alias to describe
433    /// tables in another database.
434    pub database: Option<String>,
435}
436
437/// Parameters for the `chart` tool.
438#[derive(Debug, Deserialize, JsonSchema)]
439pub struct ChartParams {
440    /// SQL query returning the data to plot (read-only SELECT/WITH/EXPLAIN/SHOW/VALUES)
441    pub sql: String,
442    /// Chart type: bar, line, scatter, or histogram
443    pub chart_type: String,
444    /// X-axis column name (required for bar/line/scatter; histogram uses this as the value column)
445    pub x: Option<String>,
446    /// Y-axis column name (required for bar/line/scatter)
447    pub y: Option<String>,
448    /// Optional series/grouping column for colored/grouped multi-series charts
449    pub series: Option<String>,
450    /// Chart title
451    pub title: Option<String>,
452    /// Output format: "png" (default) or "svg"
453    pub format: Option<String>,
454    /// Width in pixels (default 800)
455    pub width: Option<u32>,
456    /// Height in pixels (default 480)
457    pub height: Option<u32>,
458    /// Number of bins for histograms (default 20)
459    pub bins: Option<u32>,
460    /// Treat the x column as categorical rather than numeric. Auto-detected
461    /// from the first row's x value for line/scatter charts: DATE, TIMESTAMP,
462    /// TEXT, and other non-numeric types flip to categorical automatically.
463    /// Set explicitly to override auto-detection. Bar charts are always
464    /// categorical regardless of this flag.
465    pub x_as_category: Option<bool>,
466    /// Fix the x-axis range as [min, max]. Omit to auto-scale. Useful when
467    /// comparing multiple charts at a consistent scale (e.g. [0, 1500] for
468    /// population in millions) or when an outlier would distort auto-scaling.
469    /// Ignored for bar charts (which use categorical x positions).
470    pub x_range: Option<[f64; 2]>,
471    /// Fix the y-axis range as [min, max]. Omit to auto-scale.
472    /// Example: [0.0, 1.0] to pin a 0–1 index axis regardless of the data.
473    pub y_range: Option<[f64; 2]>,
474    /// Map series names to hex colors ("#rrggbb"). Series not listed here
475    /// fall back to the default color palette. Example:
476    /// {"India": "#e41a1c", "China": "#ff7f0e"}. Only meaningful when a
477    /// `series` column is set.
478    pub color_map: Option<std::collections::HashMap<String, String>>,
479    /// When true, draw the series name as a text label next to each dot
480    /// (scatter) or point (line) and suppress the legend box. Best when
481    /// each series has exactly one point (e.g. one country per dot).
482    /// Defaults to false (legend shown).
483    pub label_points: Option<bool>,
484    /// Where to write the rendered image. Parent directory is created
485    /// automatically. If omitted, a file is auto-generated under the
486    /// system temp dir (`<temp>/hyperdb-charts/chart-<ts>-<pid>-<n>.<ext>`).
487    /// Combine with `inline=true` to receive the bytes inline AND write
488    /// a file; otherwise the file is the sole output.
489    pub output_path: Option<String>,
490    /// When true, include the PNG/SVG bytes inline in the tool result.
491    /// Without `output_path` this also skips the disk write entirely
492    /// (pure inline). With `output_path` the file is written *and* the
493    /// image is returned inline. Defaults to true so MCP clients can
494    /// display the chart without a separate file-read round-trip.
495    pub inline: Option<bool>,
496    /// When false, refuse to overwrite an existing file at `output_path`
497    /// and return `PERMISSION_DENIED` without touching it. Defaults to
498    /// true (overwrite silently), matching the `export` tool.
499    pub overwrite: Option<bool>,
500    /// Target database alias for unqualified name resolution in the
501    /// chart's SQL. Omit to query the ephemeral primary. Pass
502    /// `"persistent"` or a user-attached alias to chart from there.
503    pub database: Option<String>,
504}
505
506/// Parameters for the `watch_directory` tool.
507#[derive(Debug, Deserialize, JsonSchema)]
508pub struct WatchDirectoryParams {
509    /// Absolute path to the directory to watch
510    pub path: String,
511    /// Target table name — all files in the directory are appended to this table
512    pub table: String,
513    /// Maximum number of files ingested in parallel. Defaults to 4; capped at 32.
514    /// Each in-flight ingest holds one connection to hyperd plus a transaction.
515    #[serde(default)]
516    pub max_concurrent: Option<u32>,
517    /// Target database alias. Omit (or pass `"local"`) for the ephemeral
518    /// primary. Pass `"persistent"` for the durable database, or any
519    /// user-attached writable alias. The watcher's connection pool is
520    /// built against the resolved target, so subsequent ingests land
521    /// in the right database without per-file routing.
522    ///
523    /// Detaching the alias while a watcher is active is rejected — call
524    /// `unwatch_directory` first.
525    pub database: Option<String>,
526    /// Shorthand for `database: "persistent"`. If both `database` and
527    /// `persist` are set, `database` wins.
528    pub persist: Option<bool>,
529}
530
531/// Parameters for the `unwatch_directory` tool.
532#[derive(Debug, Deserialize, JsonSchema)]
533pub struct UnwatchDirectoryParams {
534    /// Path of a currently watched directory
535    pub path: String,
536}
537
538/// Parameters for the `inspect_file` tool.
539///
540/// Dry-run a file against the same schema inference + numeric-widening pipeline
541/// that `load_file` uses, returning the inferred schema plus per-column
542/// diagnostics. Call this *before* `load_file` whenever you are unsure about
543/// types — especially for wide CSVs with large numbers, mixed integer/float
544/// columns, or values that only appear near the end of the file. Use the
545/// returned `type` + `min` / `max` to construct an explicit `schema` override
546/// for the subsequent `load_file` / `load_data` call.
547#[derive(Debug, Deserialize, JsonSchema)]
548pub struct InspectFileParams {
549    /// Absolute path to the CSV, Parquet, or Arrow IPC file to inspect.
550    /// Nothing is written to Hyper and no engine is started.
551    pub path: String,
552    /// Maximum number of sample rows / values per column to return (default
553    /// 5, max 50). Useful for checking that an override would produce the
554    /// expected types before ingesting a large file.
555    pub sample_rows: Option<u32>,
556    /// Optional dot-separated path to extract a nested data array from a
557    /// JSON file before inspecting. See `LoadFileParams::json_extract_path`
558    /// for the full path syntax and stringified-JSON handling.
559    pub json_extract_path: Option<String>,
560}
561
562/// Parameters for the `export` tool.
563#[derive(Debug, Deserialize, JsonSchema)]
564pub struct ExportParams {
565    /// SQL query to export (if omitted, exports whole table)
566    pub sql: Option<String>,
567    /// Table name (used if sql omitted)
568    pub table: Option<String>,
569    /// Output file path
570    pub path: String,
571    /// Format: csv, parquet, `arrow_ipc`, iceberg, or hyper. For `iceberg`
572    /// the `path` is a *directory* that hyperd will create (the table
573    /// root with a `metadata/` and `data/` subdir); for all other
574    /// formats it is a single file.
575    pub format: String,
576    /// If false, refuse to overwrite an existing file at `path` and return
577    /// a `PERMISSION_DENIED` error instead. Defaults to true (overwrite
578    /// silently) to match pre-flag behavior.
579    pub overwrite: Option<bool>,
580    /// Optional per-format options passed through into hyperd's `COPY
581    /// (query) TO '…' WITH (…)` clause. Keys must match hyperd's own
582    /// option names exactly; values must be strings, numbers, or
583    /// booleans (null / nested object / array are rejected). Common
584    /// knobs:
585    ///
586    /// * **parquet** — `codec` (`"snappy"` default, `"zstd"`, `"gzip"`,
587    ///   `"uncompressed"`, ...), `rows_per_row_group` (int).
588    /// * **iceberg** — everything Parquet accepts, plus `table_scheme`
589    ///   (`"metastore"` default, `"filesystem"`), `max_file_size`
590    ///   (bytes; split data across multiple parquet files).
591    /// * **csv** — `header` (bool, default true), `delimiter` (1-char
592    ///   string, default `","`), `null` (string printed for NULL,
593    ///   default `""`), `quote` (1-char string).
594    /// * **`arrow_ipc`** — none commonly needed.
595    ///
596    /// Ignored for `format = "hyper"` (which isn't a `COPY`).
597    pub format_options: Option<Value>,
598    /// Source database alias. Omit to read from the ephemeral primary.
599    /// Pass `"persistent"` or a user-attached alias to export from there.
600    /// In `table` mode, the table name is fully qualified against this
601    /// database. In `sql` mode, unqualified names in the SQL resolve
602    /// against this database for the duration of the call.
603    pub database: Option<String>,
604}
605
606/// Parameters for the `save_query` tool.
607///
608/// Persists a named read-only SQL query. After saving, the query is
609/// available as two MCP resources:
610///
611/// * `hyper://queries/{name}/definition` — JSON metadata (sql, description,
612///   `created_at`).
613/// * `hyper://queries/{name}/result` — re-runs the SQL on every read and
614///   returns the rows + query stats.
615///
616/// In ephemeral workspaces (no `--workspace`) saved queries live only for
617/// the life of the server process; in persistent workspaces they are
618/// stored in the `_hyperdb_saved_queries` meta-table and survive restarts.
619#[derive(Debug, Deserialize, JsonSchema)]
620pub struct SaveQueryParams {
621    /// Unique name identifying the query. Becomes the path component of
622    /// the resource URIs — pick something URL-safe and human-readable.
623    pub name: String,
624    /// The SQL to store. Must be a read-only statement (`SELECT` / `WITH`
625    /// / `EXPLAIN` / `SHOW` / `VALUES`); destructive statements are
626    /// rejected at save time.
627    pub sql: String,
628    /// Optional free-form description — what does this query answer?
629    pub description: Option<String>,
630}
631
632/// Parameters for the `delete_query` tool.
633#[derive(Debug, Deserialize, JsonSchema)]
634pub struct DeleteQueryParams {
635    /// Name of the saved query to remove. No-op when the name doesn't
636    /// exist; the tool returns `{"deleted": false}` in that case.
637    pub name: String,
638}
639
640/// One database to attach for the duration of a single `copy_query`
641/// call. Same kind-tagged shape as `AttachDatabaseParams` so the
642/// vocabulary stays consistent once remote kinds (`tcp` / `grpc`)
643/// arrive.
644#[derive(Debug, Deserialize, JsonSchema, Clone)]
645pub struct AttachSpec {
646    /// Alias used to qualify tables from this attachment (e.g. `src`
647    /// lets you reference `src.public.customers`). Must be a SQL
648    /// identifier and cannot be `local`.
649    pub alias: String,
650    /// Attachment kind. Only `"local_file"` is supported today; `"tcp"`
651    /// (standard remote hyperd) and `"grpc"` (Data 360 read-only Hyper)
652    /// are planned.
653    pub kind: String,
654    /// Absolute path to a `.hyper` file. Required when `kind ==
655    /// "local_file"`; ignored otherwise.
656    pub path: Option<String>,
657    /// If `true`, allow writes into this attachment. Defaults to
658    /// `false`. Must also satisfy the server's `--read-only` flag (it
659    /// always wins).
660    pub writable: Option<bool>,
661    /// What to do when `kind == "local_file"` and `path` does not yet
662    /// exist. `"error"` (default) returns `FILE_NOT_FOUND`; `"create"`
663    /// issues `CREATE DATABASE IF NOT EXISTS` first and then attaches
664    /// the resulting empty file. `"create"` requires `writable: true`
665    /// and is rejected when the server is `--read-only`.
666    pub on_missing: Option<String>,
667}
668
669/// Parameters for the `attach_database` tool. Mirrors [`AttachSpec`]
670/// except that these attachments live for the rest of the MCP session
671/// (or until `detach_database` is called).
672#[derive(Debug, Deserialize, JsonSchema)]
673pub struct AttachDatabaseParams {
674    /// Alias to register the attachment under. Must be a SQL identifier
675    /// (`[A-Za-z_][A-Za-z0-9_]{0,62}`) and cannot be `local` (reserved
676    /// for the primary workspace).
677    pub alias: String,
678    /// Attachment kind. Only `"local_file"` is supported today.
679    pub kind: String,
680    /// Absolute path to a `.hyper` file. Required when `kind ==
681    /// "local_file"`. The file must be idle — another MCP server or
682    /// `hyperd` instance holding it will cause a `RESOURCE_BUSY` error.
683    pub path: Option<String>,
684    /// If `true`, `copy_query` (and raw `execute`) may target this
685    /// attachment. Defaults to `false` so sources stay safe from
686    /// accidental mutation.
687    pub writable: Option<bool>,
688    /// What to do when `kind == "local_file"` and `path` does not yet
689    /// exist:
690    ///
691    /// * `"error"` (default) — return `FILE_NOT_FOUND`. Matches the
692    ///   pre-existing contract.
693    /// * `"create"` — issue `CREATE DATABASE IF NOT EXISTS` against the
694    ///   path first, then attach the resulting empty file. Requires
695    ///   `writable: true` (otherwise the empty DB would be unusable)
696    ///   and is rejected when the server is running with `--read-only`.
697    ///   The parent directory must already exist.
698    pub on_missing: Option<String>,
699}
700
701/// Parameters for the `detach_database` tool.
702#[derive(Debug, Deserialize, JsonSchema)]
703pub struct DetachDatabaseParams {
704    /// Alias of a previously attached database.
705    pub alias: String,
706}
707
708/// Parameters for the `copy_query` tool. Runs a read-only SELECT / WITH
709/// / VALUES statement and lands the result into a target table.
710///
711/// The inner `sql` may reference tables in the primary workspace
712/// (unqualified) as well as tables in any attachment by its fully
713/// qualified form — e.g. `src.public.customers`. The destination is
714/// resolved via `target_database` (main workspace by default).
715#[derive(Debug, Deserialize, JsonSchema)]
716pub struct CopyQueryParams {
717    /// Read-only SQL statement whose result rows will be inserted into
718    /// `target_table`. Must begin with `SELECT`, `WITH`, or `VALUES`.
719    /// `EXPLAIN` / `SHOW` are rejected because their output shape isn't
720    /// row-compatible with a target table.
721    pub sql: String,
722    /// Unqualified destination table name. Always lands in the
723    /// `public` schema of the database identified by `target_database`.
724    pub target_table: String,
725    /// How to reconcile with any existing target table:
726    ///
727    /// * `"create"` — error if the target already exists; create from
728    ///   the query's result schema via `CREATE TABLE AS`.
729    /// * `"append"` — error if the target does not exist; rows are
730    ///   appended via `INSERT INTO ... SELECT`.
731    /// * `"replace"` — drop (if any) and recreate, atomically.
732    pub mode: String,
733    /// Alias of the destination database. `None` and `"local"` both
734    /// mean the server's primary workspace. Any other value must refer
735    /// to an attachment registered with `writable: true`.
736    pub target_database: Option<String>,
737    /// Optional list of databases to attach for the duration of this
738    /// call only. Detached automatically even if the query fails.
739    /// Aliases used here must not already be in use.
740    pub temp_attach: Option<Vec<AttachSpec>>,
741}
742
743/// Parameters for the `set_table_metadata` tool.
744///
745/// Writes prose fields to the `_table_catalog` row for `table`. Unset
746/// fields are left unchanged; passing an explicit empty string (`""`)
747/// clears a field. Mechanical fields (`loaded_at`, `last_refreshed_at`,
748/// `row_count`, `load_tool`, `load_params`) are managed by the server
749/// and cannot be set through this tool.
750#[derive(Debug, Deserialize, JsonSchema)]
751pub struct SetTableMetadataParams {
752    /// Target table name. Must already exist in the workspace and have a
753    /// catalog entry — load the table first (or run `execute CREATE
754    /// TABLE`) so the server auto-stubs the row.
755    pub table: String,
756    /// Where the data came from (URL, S3 path, internal system name).
757    pub source_url: Option<String>,
758    /// Short description of the dataset (what's in the table, how to
759    /// interpret it).
760    pub source_description: Option<String>,
761    /// Why this data is in the workspace — what questions it's intended
762    /// to answer.
763    pub purpose: Option<String>,
764    /// License or attribution requirements for the source data.
765    pub license: Option<String>,
766    /// Free-form notes: refresh instructions, known gotchas, caveats.
767    pub notes: Option<String>,
768    /// Machine-actionable download URL for the raw data file. Distinct
769    /// from `source_url` (which is a human-readable page/reference).
770    /// Enables mechanical refresh: the server can re-ingest the table
771    /// from this URL + `load_params` without prose parsing.
772    pub data_url: Option<String>,
773    /// Target database alias for the catalog write. Omit (or pass
774    /// `"local"` / `"persistent"`) to update the persistent catalog —
775    /// matches the default for the ephemeral primary's tables.
776    /// Pass any user-attached writable alias to update that DB's
777    /// per-database `_table_catalog` instead. Read-only attachments
778    /// are rejected with a clear "re-attach with writable:true"
779    /// message.
780    pub database: Option<String>,
781}
782
783// --- Prompt argument structs ---
784
785/// Arguments for the `analyze-table` prompt.
786#[derive(Debug, Deserialize, JsonSchema)]
787pub struct AnalyzeTableArgs {
788    /// Name of the table to analyze
789    pub table: String,
790}
791
792/// Arguments for the `compare-tables` prompt.
793#[derive(Debug, Deserialize, JsonSchema)]
794pub struct CompareTablesArgs {
795    /// First table to compare
796    pub table_a: String,
797    /// Second table to compare
798    pub table_b: String,
799}
800
801/// Arguments for the `data-quality` prompt.
802#[derive(Debug, Deserialize, JsonSchema)]
803pub struct DataQualityArgs {
804    /// Name of the table to assess
805    pub table: String,
806}
807
808/// Arguments for the `suggest-queries` prompt.
809#[derive(Debug, Deserialize, JsonSchema)]
810pub struct SuggestQueriesArgs {
811    /// Name of the table to suggest queries for
812    pub table: String,
813    /// Optional goal or focus area (e.g. "find top customers", "detect anomalies")
814    pub goal: Option<String>,
815}
816
817// --- Server ---
818
819/// The MCP server that registers all Hyper tools and routes invocations.
820///
821/// The `Engine` is lazily initialized behind a `Mutex<Option<Engine>>` so that
822/// the expensive `HyperProcess` startup only happens on the first actual tool
823/// call, not during MCP handshake. This keeps `initialize` fast and avoids
824/// starting `hyperd` if the client never calls a tool.
825pub struct HyperMcpServer {
826    engine: Arc<Mutex<Option<Engine>>>,
827    /// `true` once [`Self::ensure_catalog_ready`] has successfully run on
828    /// the current engine, so we only try to create / reconcile
829    /// `_table_catalog` once per process. Reset to `false` if the
830    /// underlying engine is torn down (e.g. connection lost) so the next
831    /// call re-bootstraps.
832    catalog_ready: Arc<Mutex<bool>>,
833    watchers: Arc<crate::watcher::WatcherRegistry>,
834    saved_queries: Arc<dyn SavedQueryStore>,
835    subscriptions: Arc<SubscriptionRegistry>,
836    /// Registry of `ATTACH DATABASE`s requested via `attach_database`.
837    /// Lives at the server level (not the engine level) so the list
838    /// survives `ConnectionLost` reconnects: [`Self::with_engine`]
839    /// calls [`AttachRegistry::replay_all`] after building a fresh
840    /// engine.
841    attachments: Arc<AttachRegistry>,
842    /// Path to the persistent `.hyper` file, or `None` for `--ephemeral-only`.
843    /// Threaded into `Engine::new` so the engine can attach it under the
844    /// reserved `"persistent"` alias.
845    workspace_path: Option<String>,
846    read_only: bool,
847    /// Skip the shared daemon and spawn a private `hyperd` (legacy behavior).
848    no_daemon: bool,
849    /// Last time a heartbeat was sent to the daemon (debounced to avoid per-call TCP overhead).
850    last_heartbeat: std::sync::Mutex<std::time::Instant>,
851    /// MCP client name from the `initialize` handshake (e.g. "Claude Code",
852    /// "cursor-mcp-client"). Populated once per session; used for catalog
853    /// provenance tracking (`created_by` / `last_modified_by`).
854    client_name: std::sync::Mutex<Option<String>>,
855    // Under rmcp 1.x the router fields are constructed for downstream
856    // macro-generated dispatch but not read through a direct field access
857    // that the compiler can see. Keep them; the `#[tool_router]` /
858    // `#[prompt_router]` attribute macros on impl blocks wire the routing.
859    #[expect(dead_code, reason = "constructed for rmcp 1.x macro-based dispatch")]
860    tool_router: ToolRouter<Self>,
861    #[expect(dead_code, reason = "constructed for rmcp 1.x macro-based dispatch")]
862    prompt_router: PromptRouter<Self>,
863}
864
865impl std::fmt::Debug for HyperMcpServer {
866    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
867        f.debug_struct("HyperMcpServer")
868            .field("persistent_path", &self.workspace_path)
869            .field("read_only", &self.read_only)
870            .field("no_daemon", &self.no_daemon)
871            .finish_non_exhaustive()
872    }
873}
874
875impl HyperMcpServer {
876    /// Create a server instance. Pass `Some(path)` for persistent workspace,
877    /// `None` for ephemeral (temp directory, auto-cleaned).
878    ///
879    /// The saved-queries store is chosen to match the workspace mode:
880    /// persistent workspaces get a [`crate::saved_queries::WorkspaceStore`]
881    /// (backed by a meta-table in the `.hyper` file so queries survive
882    /// restarts), ephemeral workspaces get an in-memory
883    /// [`crate::saved_queries::SessionStore`].
884    ///
885    /// When `read_only` is `true`, the `execute`, `load_data`, `load_file`,
886    /// `save_query`, `delete_query`, and `set_table_metadata` tools return
887    /// a `ReadOnlyViolation` error, and exporting to the `hyper` format
888    /// (which is a raw file copy, harmless) remains allowed.
889    ///
890    /// When `bare` is `true`, the server does not create or maintain the
891    /// `_table_catalog` table, and saved queries fall back to the in-memory
892    /// [`crate::saved_queries::SessionStore`] regardless of `workspace_path`
893    /// `persistent_path` is the resolved path to the persistent database
894    /// (`Some`) or `None` for `--ephemeral-only` mode.
895    pub fn new(persistent_path: Option<String>, read_only: bool) -> Self {
896        Self::with_options(persistent_path, read_only, false)
897    }
898
899    /// Create a server instance with explicit daemon control.
900    pub fn with_no_daemon(
901        persistent_path: Option<String>,
902        read_only: bool,
903        no_daemon: bool,
904    ) -> Self {
905        Self::with_options(persistent_path, read_only, no_daemon)
906    }
907
908    fn with_options(persistent_path: Option<String>, read_only: bool, no_daemon: bool) -> Self {
909        // Saved queries persist when a persistent database is available;
910        // session storage takes over for `--ephemeral-only` sessions.
911        let saved_queries: Arc<dyn SavedQueryStore> = build_store(persistent_path.as_deref());
912        Self {
913            engine: Arc::new(Mutex::new(None)),
914            catalog_ready: Arc::new(Mutex::new(false)),
915            watchers: Arc::new(crate::watcher::WatcherRegistry::new()),
916            saved_queries,
917            subscriptions: Arc::new(SubscriptionRegistry::new()),
918            // The catalog policy is now uniform: seed `_table_catalog`
919            // whenever MCP creates a fresh `.hyper` file. The opt-out
920            // `--bare` path was removed; users wanting a pristine file
921            // can `DROP TABLE _table_catalog` after creation.
922            attachments: Arc::new(AttachRegistry::new()),
923            workspace_path: persistent_path,
924            read_only,
925            no_daemon,
926            last_heartbeat: std::sync::Mutex::new(std::time::Instant::now()),
927            client_name: std::sync::Mutex::new(None),
928            tool_router: Self::tool_router(),
929            prompt_router: Self::prompt_router(),
930        }
931    }
932
933    /// Return a clone of the subscription registry so background tasks
934    /// (notably the directory watcher) can fire resource updates after
935    /// their own ingest completes.
936    #[must_use]
937    pub fn subscriptions_handle(&self) -> Arc<SubscriptionRegistry> {
938        Arc::clone(&self.subscriptions)
939    }
940
941    /// Fire resource-updated notifications for every URI affected by a
942    /// change to the given table. Targets the workspace/table-list/readme
943    /// summary resources plus the three per-table URIs (schema, sample,
944    /// csv-sample). Callers that have just added or dropped a table
945    /// should also call [`Self::notify_resource_list_changed`] so
946    /// subscribers refresh their resource catalog.
947    pub(crate) fn notify_table_changed(&self, table: &str) {
948        for uri in uris_for_table_change(table) {
949            self.subscriptions.notify_updated(&uri);
950        }
951    }
952
953    /// Fire updates for every URI that summarises the workspace as a
954    /// whole (workspace, tables list, readme). Used after watcher-style
955    /// bulk mutations where the single-table helper isn't specific
956    /// enough.
957    pub(crate) fn notify_workspace_changed(&self) {
958        for uri in uris_for_workspace_change() {
959            self.subscriptions.notify_updated(uri);
960        }
961    }
962
963    /// Fire a `notifications/resources/list_changed` broadcast. Call
964    /// after any operation that adds or removes resources from the
965    /// `resources/list` catalog — dropped tables, saved queries
966    /// created / deleted, watcher ingest of a brand-new table.
967    pub(crate) fn notify_resource_list_changed(&self) {
968        self.subscriptions.notify_list_changed();
969    }
970
971    /// The MCP client name from the `initialize` handshake, or `None` if
972    /// the handshake hasn't completed yet. Used for catalog provenance.
973    fn client_name(&self) -> Option<String> {
974        self.client_name.lock().ok().and_then(|g| g.clone())
975    }
976
977    /// Return a clone of the engine Arc so background tasks (watchers) can
978    /// share access to the same lazy-initialized engine instance.
979    #[must_use]
980    pub fn engine_handle(&self) -> Arc<Mutex<Option<Engine>>> {
981        Arc::clone(&self.engine)
982    }
983
984    /// Return a clone of the watcher registry handle for tool handlers.
985    #[must_use]
986    pub fn watchers_handle(&self) -> Arc<crate::watcher::WatcherRegistry> {
987        Arc::clone(&self.watchers)
988    }
989
990    /// Return a clone of the attachments registry handle for tool
991    /// handlers and the `with_engine` replay path.
992    #[must_use]
993    pub fn attachments_handle(&self) -> Arc<AttachRegistry> {
994        Arc::clone(&self.attachments)
995    }
996
997    /// Whether the server is running in read-only mode.
998    #[must_use]
999    pub fn is_read_only(&self) -> bool {
1000        self.read_only
1001    }
1002
1003    /// Return a `ReadOnlyViolation` error if the server is in read-only mode.
1004    /// Used as an early guard at the top of mutating tool handlers.
1005    fn check_writable(&self, operation: &str) -> Result<(), McpError> {
1006        if self.read_only {
1007            Err(McpError::new(
1008                ErrorCode::ReadOnlyViolation,
1009                format!("Operation '{operation}' is not permitted in read-only mode"),
1010            ))
1011        } else {
1012            Ok(())
1013        }
1014    }
1015
1016    /// Resolve the effective database alias from a tool's `database` and
1017    /// `persist` parameters. Returns `None` when the target is the primary
1018    /// (ephemeral) — callers should leave SQL unqualified. Returns
1019    /// `Some(alias)` when targeting a non-primary database.
1020    ///
1021    /// When `require_writable` is true, verifies the target alias is
1022    /// either the primary, `"persistent"` (always writable), or a
1023    /// user-attached database with `writable: true`.
1024    fn resolve_db(
1025        &self,
1026        engine: &Engine,
1027        database: Option<&str>,
1028        persist: Option<bool>,
1029        require_writable: bool,
1030    ) -> Result<Option<String>, McpError> {
1031        let effective = match (database, persist) {
1032            (Some(db), _) => Some(db),
1033            (None, Some(true)) => Some(Engine::PERSISTENT_ALIAS),
1034            _ => None,
1035        };
1036        // Filter LOCAL_ALIAS ("local") — treat as primary
1037        let effective = effective.filter(|s| !s.eq_ignore_ascii_case(crate::attach::LOCAL_ALIAS));
1038
1039        let resolved = engine.resolve_target_db(effective)?;
1040        let primary = engine.primary_db_name();
1041
1042        if resolved == primary {
1043            return Ok(None);
1044        }
1045
1046        if require_writable && resolved != Engine::PERSISTENT_ALIAS {
1047            match self.attachments.get(&resolved) {
1048                None => {
1049                    return Err(McpError::new(
1050                        ErrorCode::InvalidArgument,
1051                        format!(
1052                            "database '{resolved}' is not attached. \
1053                             Call attach_database first, or use \"persistent\"."
1054                        ),
1055                    ));
1056                }
1057                Some(entry) if !entry.writable => {
1058                    return Err(McpError::new(
1059                        ErrorCode::InvalidArgument,
1060                        format!(
1061                            "database '{resolved}' was attached read-only. \
1062                             Re-attach with writable:true to write to it."
1063                        ),
1064                    ));
1065                }
1066                _ => {}
1067            }
1068        }
1069
1070        Ok(Some(resolved))
1071    }
1072
1073    /// Lazily start the Hyper engine on first use, returning a mutex guard
1074    /// that holds a reference to the initialized `Engine`.
1075    ///
1076    /// When the engine was just created, resets the
1077    /// [`Self::catalog_ready`] flag so the subsequent `with_engine` call
1078    /// runs the catalog bootstrap. We can't run the bootstrap here
1079    /// because it needs to issue SQL back through `Engine`, and we're
1080    /// still holding the outer lock.
1081    fn ensure_engine(&self) -> Result<std::sync::MutexGuard<'_, Option<Engine>>, McpError> {
1082        let mut guard = self
1083            .engine
1084            .lock()
1085            .map_err(|_| McpError::new(ErrorCode::InternalError, "Lock poisoned"))?;
1086        if guard.is_none() {
1087            tracing::info!(
1088                persistent_db = self.workspace_path.as_deref().unwrap_or("<ephemeral-only>"),
1089                no_daemon = self.no_daemon,
1090                "initializing hyper engine"
1091            );
1092            let engine = if self.no_daemon {
1093                Engine::new_no_daemon(self.workspace_path.clone())?
1094            } else {
1095                Engine::new(self.workspace_path.clone())?
1096            };
1097            tracing::info!(
1098                ephemeral_path = %engine.ephemeral_path().display(),
1099                persistent_path = ?engine.persistent_path(),
1100                log_dir = %engine.log_dir().display(),
1101                "engine ready"
1102            );
1103            // Replay any attachments tracked across the previous
1104            // engine's lifetime *before* handing the engine out to a
1105            // tool — otherwise the first post-reconnect tool call
1106            // would see the attachments missing from Hyper's view even
1107            // though the registry still lists them. Logs replay
1108            // failures; those entries are dropped from the registry
1109            // inside `replay_all` so a single stale attachment doesn't
1110            // block recovery.
1111            if let Err(e) = self.attachments.replay_all(&engine) {
1112                tracing::warn!(err = %e.message, "failed to replay attachments on new engine");
1113            }
1114            *guard = Some(engine);
1115            // New engine → catalog may need to be created/reconciled
1116            // even if we already did it against a prior (now-dead)
1117            // engine.
1118            if let Ok(mut ready) = self.catalog_ready.lock() {
1119                *ready = false;
1120            }
1121        }
1122        Ok(guard)
1123    }
1124
1125    /// Eagerly initialize the engine at server startup, before any tool call
1126    /// arrives. This makes read-only observer tools like `status` able to
1127    /// report full stats on the very first call without having to trigger
1128    /// initialization themselves — `status` stays a pure, non-blocking
1129    /// observer (honoring issue #118).
1130    ///
1131    /// Errors are logged and swallowed rather than propagated: if `hyperd` is
1132    /// unreachable at startup the server still comes up, and the first
1133    /// data-plane tool call will retry initialization via `with_engine`.
1134    pub fn warm_up_engine(&self) {
1135        match self.ensure_engine() {
1136            Ok(_guard) => {
1137                tracing::info!("engine initialized eagerly at startup");
1138            }
1139            Err(e) => {
1140                tracing::warn!(
1141                    err = %e.message,
1142                    "eager engine initialization failed at startup; \
1143                     will retry on first data-plane tool call"
1144                );
1145            }
1146        }
1147    }
1148
1149    /// Idempotently create and reconcile `_table_catalog` on first call
1150    /// per engine. No-op in bare or read-only mode (read-only can't
1151    /// mutate; bare callers never wanted the catalog in the first place).
1152    ///
1153    /// Catalog failures during bootstrap are logged at WARN but do not
1154    /// fail the outer tool call — a broken catalog should never block a
1155    /// legitimate query. The `catalog_ready` flag still flips to `true`
1156    /// so we don't retry the same failing bootstrap on every call.
1157    fn ensure_catalog_ready(&self, engine: &Engine) {
1158        if self.read_only {
1159            return;
1160        }
1161        let Ok(mut ready) = self.catalog_ready.lock() else {
1162            return;
1163        };
1164        if *ready {
1165            return;
1166        }
1167        if let Err(e) = crate::table_catalog::ensure_exists(engine) {
1168            tracing::warn!(err = %e.message, "failed to ensure _table_catalog exists");
1169        }
1170        if let Err(e) = crate::table_catalog::reconcile(engine) {
1171            tracing::warn!(err = %e.message, "failed to reconcile _table_catalog on startup");
1172        }
1173        *ready = true;
1174    }
1175
1176    /// Best-effort catalog upsert after a successful ingest. Logs and
1177    /// swallows errors — a bookkeeping failure should never fail an
1178    /// otherwise-successful load.
1179    ///
1180    /// Routes the upsert to `target_db`'s `_table_catalog`. The
1181    /// catalog is lazily seeded if absent. `target_db = None` and
1182    /// `target_db = Some("persistent")` both write to the persistent
1183    /// catalog (the single-engine ephemeral primary stubs survive
1184    /// there for the session). User-attached writable aliases get
1185    /// their own per-DB catalog. Read-only attachments are rejected
1186    /// upstream by `resolve_db(require_writable=true)` so this helper
1187    /// never sees them.
1188    fn after_ingest_catalog_update(
1189        &self,
1190        engine: &Engine,
1191        table_name: &str,
1192        load_tool: &'static str,
1193        load_params: Option<&str>,
1194        row_count: Option<i64>,
1195        target_db: Option<&str>,
1196    ) {
1197        if let Err(e) = crate::table_catalog::upsert_stub_in(
1198            engine,
1199            table_name,
1200            load_tool,
1201            load_params,
1202            row_count,
1203            true,
1204            target_db,
1205            self.client_name().as_deref(),
1206        ) {
1207            tracing::warn!(
1208                table = %table_name,
1209                target_db = ?target_db,
1210                err = %e.message,
1211                "failed to update _table_catalog after ingest"
1212            );
1213        }
1214    }
1215
1216    /// Best-effort catalog reconcile after a DDL/DML `execute`. Same
1217    /// error-swallowing rationale as [`Self::after_ingest_catalog_update`].
1218    ///
1219    /// Reconciles persistent first, then the user-attached writable
1220    /// target if one was passed and it isn't persistent. Without the
1221    /// second pass, raw DDL like `DROP TABLE` against a user-attached
1222    /// alias leaves the dropped table's row stranded in that DB's
1223    /// `_table_catalog` indefinitely (bootstrap reconcile only walks
1224    /// persistent, and tools like `describe` would keep listing it).
1225    #[expect(
1226        clippy::unused_self,
1227        reason = "&self required for method-call dispatch; body uses only engine + target_db"
1228    )]
1229    fn after_execute_catalog_update(&self, engine: &Engine, target_db: Option<&str>) {
1230        if let Err(e) = crate::table_catalog::reconcile_in(engine, None) {
1231            tracing::warn!(
1232                err = %e.message,
1233                "failed to reconcile persistent _table_catalog after execute"
1234            );
1235        }
1236        if let Some(alias) = target_db {
1237            if !alias.eq_ignore_ascii_case(Engine::PERSISTENT_ALIAS) {
1238                if let Err(e) = crate::table_catalog::reconcile_in(engine, Some(alias)) {
1239                    tracing::warn!(
1240                        target_db = alias,
1241                        err = %e.message,
1242                        "failed to reconcile user-DB _table_catalog after execute"
1243                    );
1244                }
1245            }
1246        }
1247    }
1248
1249    /// Convenience wrapper: acquire the engine and run a closure against it.
1250    ///
1251    /// If the closure returns an error classified as
1252    /// [`ErrorCode::ConnectionLost`], the engine is dropped from the mutex
1253    /// before the error is returned to the caller. The next tool call will
1254    /// observe `engine.is_none()` and transparently re-spawn `hyperd` via
1255    /// [`Self::ensure_engine`]. Callers then just retry and the server
1256    /// heals itself.
1257    fn with_engine<F, R>(&self, f: F) -> Result<R, McpError>
1258    where
1259        F: FnOnce(&Engine) -> Result<R, McpError>,
1260    {
1261        let mut guard = self.ensure_engine()?;
1262        let engine = guard.as_ref().expect("ensure_engine guarantees Some");
1263        // Bootstrap the catalog exactly once per engine. Intentionally
1264        // runs *inside* `with_engine` (not `ensure_engine`) so the
1265        // catalog SQL can see errors classified via the normal error
1266        // path. No-op in bare or read-only mode.
1267        self.ensure_catalog_ready(engine);
1268        // In daemon mode, send a heartbeat so the daemon knows we're still active.
1269        // Debounced to avoid per-call TCP overhead (only sends if >60s since last).
1270        // Pass the health port from the engine we already hold — calling
1271        // self.engine.lock() here would deadlock (we already hold that mutex).
1272        if !self.no_daemon {
1273            self.maybe_send_heartbeat(engine.daemon_health_port());
1274        }
1275        let result = f(engine);
1276        if let Err(e) = &result {
1277            tracing::debug!(code = ?e.code, message = %e.message, "tool call returned error");
1278            if e.code == ErrorCode::ConnectionLost {
1279                tracing::warn!(
1280                    // Matches both the "hyperd crashed / socket closed" family
1281                    // and the "wire desynchronized" family — see
1282                    // [`crate::error::is_connection_lost`] for the full
1283                    // classifier and both triggers.
1284                    "connection to hyperd lost or desynchronized ({}); \
1285                     dropping engine so next call reconnects",
1286                    e.message
1287                );
1288                *guard = None;
1289                // Reset so the next call re-bootstraps the catalog
1290                // against the fresh engine.
1291                if let Ok(mut ready) = self.catalog_ready.lock() {
1292                    *ready = false;
1293                }
1294                // Tell the daemon hyperd looks dead from over here. The daemon
1295                // will pick up the flag on its next monitor tick and restart.
1296                // Skipped in --no-daemon mode because there's no daemon to tell.
1297                if !self.no_daemon {
1298                    crate::daemon::health::report_hyperd_error_to_daemon();
1299                }
1300            }
1301        }
1302        result
1303    }
1304
1305    /// Best-effort heartbeat to keep the daemon alive while this client is active.
1306    /// Debounced: only sends if more than 60 seconds have elapsed since the last heartbeat,
1307    /// avoiding a new TCP connection on every tool call.
1308    ///
1309    /// Accepts the daemon health port directly (from the caller's already-held
1310    /// engine reference) to avoid re-locking `self.engine` — which would deadlock
1311    /// since `with_engine` holds that mutex when calling us.
1312    fn maybe_send_heartbeat(&self, daemon_health_port: Option<u16>) {
1313        const HEARTBEAT_INTERVAL: std::time::Duration = std::time::Duration::from_secs(60);
1314        let should_send = self
1315            .last_heartbeat
1316            .lock()
1317            .is_ok_and(|guard| guard.elapsed() >= HEARTBEAT_INTERVAL);
1318        if should_send {
1319            if let Some(port) = daemon_health_port {
1320                let _ = crate::daemon::health::send_command(port, "HEARTBEAT");
1321                if let Ok(mut guard) = self.last_heartbeat.lock() {
1322                    *guard = std::time::Instant::now();
1323                }
1324            }
1325        }
1326    }
1327
1328    /// Build a degraded `status` response that answers instantly without the
1329    /// engine lock. Used when `try_lock` fails because another tool call holds
1330    /// the mutex — so diagnostics never hang behind a stalled data-plane op.
1331    ///
1332    /// Includes everything answerable from `self` fields + a fast daemon-health
1333    /// check (read `daemon.json` + one PING to the known health port, max
1334    /// ~300ms). Omits `table_count`, `total_rows`, `disk_usage_bytes`,
1335    /// `ephemeral_path`, and `logs` (which require the engine / SQL against
1336    /// hyperd).
1337    ///
1338    /// Clients should check `engine_busy: true` and retry `status` later if
1339    /// they need the full stats, or wait for the in-progress operation to finish.
1340    fn status_degraded(&self) -> Value {
1341        // Use discover() — NOT find_running_daemon(). discover() reads the
1342        // daemon.json file + one PING to the known health port (~1ms if alive,
1343        // 300ms timeout if dead). find_running_daemon() adds a 16-port scan on
1344        // failure (up to 4.8s), which would defeat the "instant response" goal.
1345        let (hyperd_running, engine_block) = if let Some(info) =
1346            crate::daemon::discovery::discover()
1347        {
1348            (
1349                true,
1350                json!({
1351                    "mode": "daemon",
1352                    "hyperd_endpoint": info.hyperd_endpoint,
1353                    "daemon_health_port": info.health_port,
1354                }),
1355            )
1356        } else if self.no_daemon {
1357            // Local mode; can't determine hyperd state without the engine.
1358            (
1359                false,
1360                json!({ "mode": "local", "hyperd_endpoint": null, "daemon_health_port": null }),
1361            )
1362        } else {
1363            (
1364                false,
1365                json!({ "mode": "daemon", "hyperd_endpoint": null, "daemon_health_port": null }),
1366            )
1367        };
1368
1369        let persistent_path = self
1370            .workspace_path
1371            .as_ref()
1372            .map_or(Value::Null, |p| Value::String(p.clone()));
1373
1374        let attachments: Vec<Value> = self
1375            .attachments
1376            .list()
1377            .iter()
1378            .map(super::attach::AttachedDb::to_json)
1379            .collect();
1380
1381        json!({
1382            "engine_busy": true,
1383            "hyperd_running": hyperd_running,
1384            "persistent_path": persistent_path,
1385            "has_persistent": self.workspace_path.is_some(),
1386            "engine": engine_block,
1387            "hyper_rust_api_version": crate::version::mcp_version_string(),
1388            "watchers": self.watchers.to_json(),
1389            "read_only": self.read_only,
1390            "attachments": attachments,
1391        })
1392    }
1393
1394    /// Run a closure that accesses the saved-query store.
1395    ///
1396    /// Some store variants (notably
1397    /// [`crate::saved_queries::WorkspaceStore`]) need an `Engine` handle
1398    /// to run SQL against the meta-table; others
1399    /// ([`crate::saved_queries::SessionStore`]) ignore the engine entirely.
1400    /// For persistent workspaces we spin the engine up lazily (same path
1401    /// as every tool call), for ephemeral workspaces we skip it so the
1402    /// session-only store doesn't pay a `hyperd` startup tax.
1403    fn with_saved_query_store<F, R>(&self, f: F) -> Result<R, McpError>
1404    where
1405        F: FnOnce(Option<&Engine>) -> Result<R, McpError>,
1406    {
1407        if self.workspace_path.is_some() {
1408            self.with_engine(|engine| f(Some(engine)))
1409        } else {
1410            f(None)
1411        }
1412    }
1413
1414    #[expect(
1415        clippy::unnecessary_wraps,
1416        reason = "signature retained for API symmetry / future fallibility; returning Result/Option keeps callers from breaking when the function later grows failure cases"
1417    )]
1418    /// Wrap a successful JSON value as an MCP `CallToolResult` with both
1419    /// `structuredContent` (for spec-2025-06-18 typed clients) and a
1420    /// pretty-printed `text` block (for older clients that don't yet read
1421    /// `structuredContent`). Both representations carry the same JSON.
1422    fn ok_content(val: Value) -> Result<CallToolResult, rmcp::ErrorData> {
1423        let text = serde_json::to_string_pretty(&val).unwrap_or_default();
1424        let mut result = CallToolResult::structured(val);
1425        // CallToolResult::structured includes a stringified copy in `content`;
1426        // replace it with a pretty-printed version for human-readable display
1427        // in older clients.
1428        result.content = vec![Content::text(text)];
1429        Ok(result)
1430    }
1431
1432    /// Pretty-print a SQL string using the `PostgreSQL` dialect formatter.
1433    /// Falls back to the original string if formatting fails or produces empty output.
1434    fn fmt_sql(sql: &str) -> String {
1435        let opts = FormatOptions {
1436            indent: Indent::Spaces(2),
1437            uppercase: Some(true),
1438            lines_between_queries: 1,
1439            ..FormatOptions::default()
1440        };
1441        let formatted = sqlformat::format(sql, &SqlQueryParams::None, &opts);
1442        if formatted.trim().is_empty() {
1443            sql.to_owned()
1444        } else {
1445            formatted
1446        }
1447    }
1448
1449    #[expect(
1450        clippy::unnecessary_wraps,
1451        reason = "signature retained for API symmetry / future fallibility; returning Result/Option keeps callers from breaking when the function later grows failure cases"
1452    )]
1453    #[expect(
1454        clippy::needless_pass_by_value,
1455        reason = "call-site ergonomics: function consumes logically-owned parameters, refactoring signatures is not worth per-site churn"
1456    )]
1457    /// Wrap an `McpError` as an MCP `CallToolResult` with `isError: true`.
1458    /// The structured error (code + message + suggestion) is exposed both as
1459    /// `structuredContent` (spec 2025-06-18) and as a pretty-printed text block
1460    /// for older clients.
1461    fn err_content(e: McpError) -> Result<CallToolResult, rmcp::ErrorData> {
1462        let err_val = serde_json::to_value(&e).unwrap_or(Value::String(e.to_string()));
1463        let body = json!({"error": err_val});
1464        let text = serde_json::to_string_pretty(&body).unwrap_or_default();
1465        let mut result = CallToolResult::structured_error(body);
1466        result.content = vec![Content::text(text)];
1467        Ok(result)
1468    }
1469}
1470
1471#[tool_router]
1472impl HyperMcpServer {
1473    /// Ingest inline data (JSON or CSV) and run a SQL query in one call. Creates a temp table, queries, discards.
1474    #[tool(
1475        description = "Ingest inline data (JSON or CSV) and run a SQL query in one call. Creates a temp table, queries, discards."
1476    )]
1477    fn query_data(
1478        &self,
1479        Parameters(params): Parameters<QueryDataParams>,
1480    ) -> Result<CallToolResult, rmcp::ErrorData> {
1481        let result = self.with_engine(|engine| {
1482            let tname = params.table_name.unwrap_or_else(|| "data".into());
1483            let temp_table = format!("_tmp_{}_{}", tname, rand_suffix());
1484            let fmt = params.format.unwrap_or_else(|| detect_format(&params.data));
1485            let schema_override = crate::schema::normalize_schema_param(params.schema.as_ref())?;
1486            let opts = IngestOptions {
1487                table: temp_table.clone(),
1488                mode: "replace".into(),
1489                schema_override,
1490                merge_key: None,
1491                target_db: None,
1492            };
1493
1494            let ingest_result = match fmt.as_str() {
1495                "csv" => ingest_csv(engine, &params.data, &opts),
1496                _ => ingest_json(engine, &params.data, &opts),
1497            }?;
1498
1499            let query_sql = params.sql.replace(&tname, &temp_table);
1500            let rows = engine.execute_query_to_json(&query_sql)?;
1501            let _ = engine.execute_command(&format!("DROP TABLE IF EXISTS \"{temp_table}\""));
1502
1503            Ok(json!({
1504                "sql": Self::fmt_sql(&params.sql),
1505                "result": rows,
1506                "stats": ingest_result.stats.to_json(),
1507            }))
1508        });
1509
1510        match result {
1511            Ok(val) => Self::ok_content(val),
1512            Err(e) => Self::err_content(e),
1513        }
1514    }
1515
1516    /// Ingest a file (CSV, JSON, JSONL, Parquet, Arrow IPC) and run a SQL query in one call.
1517    #[tool(
1518        description = "Ingest a file (CSV, JSON, JSONL / NDJSON, Parquet, Arrow IPC) and run a SQL query in one call. JSON files may be either a top-level array of objects or newline-delimited JSON (JSONL); the format is auto-detected from the first byte. Use `json_extract_path` to extract a nested data array from a JSON wrapper file (e.g., MCP tool responses saved to disk). The path is dot-separated; numeric segments index into arrays; string values are automatically parsed as JSON."
1519    )]
1520    fn query_file(
1521        &self,
1522        Parameters(params): Parameters<QueryFileParams>,
1523    ) -> Result<CallToolResult, rmcp::ErrorData> {
1524        let result = self.with_engine(|engine| {
1525            crate::attach::validate_input_path(&params.path, "data file")?;
1526            let stem = std::path::Path::new(&params.path)
1527                .file_stem()
1528                .and_then(|s| s.to_str())
1529                .unwrap_or("file")
1530                .to_string();
1531            let tname = params.table_name.unwrap_or_else(|| stem.clone());
1532            let temp_table = format!("_tmp_{}_{}", tname, rand_suffix());
1533            let schema_override = crate::schema::normalize_schema_param(params.schema.as_ref())?;
1534            let opts = IngestOptions {
1535                table: temp_table.clone(),
1536                mode: "replace".into(),
1537                schema_override,
1538                merge_key: None,
1539                target_db: None,
1540            };
1541
1542            let ingest_result = if let Some(ref json_path) = params.json_extract_path {
1543                let raw = std::fs::read_to_string(&params.path).map_err(|e| {
1544                    McpError::new(
1545                        ErrorCode::FileNotFound,
1546                        format!("Cannot read file '{}': {e}", params.path),
1547                    )
1548                })?;
1549                let extracted = crate::ingest::extract_json_path(&raw, json_path)?;
1550                let array_text = crate::ingest::normalize_json_or_jsonl(&extracted)?;
1551                let mut result = ingest_json(engine, &array_text, &opts)?;
1552                result.stats.operation = "query_file".into();
1553                result.stats.bytes_read = std::fs::metadata(&params.path).map_or(0, |m| m.len());
1554                result.stats.file_format = Some("json".into());
1555                result
1556            } else {
1557                match detect_file_format(std::path::Path::new(&params.path)) {
1558                    InferredFileFormat::Parquet => ingest_parquet_file(engine, &params.path, &opts),
1559                    InferredFileFormat::ArrowIpc => {
1560                        ingest_arrow_ipc_file(engine, &params.path, &opts)
1561                    }
1562                    InferredFileFormat::Json => ingest_json_file(engine, &params.path, &opts),
1563                    InferredFileFormat::Csv => ingest_csv_file(engine, &params.path, &opts),
1564                }?
1565            };
1566
1567            let query_sql = params.sql.replace(&tname, &temp_table);
1568            let rows = engine.execute_query_to_json(&query_sql)?;
1569            let _ = engine.execute_command(&format!("DROP TABLE IF EXISTS \"{temp_table}\""));
1570
1571            Ok(json!({
1572                "sql": Self::fmt_sql(&params.sql),
1573                "result": rows,
1574                "stats": ingest_result.stats.to_json(),
1575            }))
1576        });
1577
1578        match result {
1579            Ok(val) => Self::ok_content(val),
1580            Err(e) => Self::err_content(e),
1581        }
1582    }
1583
1584    /// Load inline data (JSON or CSV) into a named workspace table.
1585    #[tool(
1586        description = "Load inline data (JSON or CSV) into a named workspace table. Supports partial `schema` overrides keyed by column name — only list the columns you want to correct, the rest keep their inferred type. On SchemaMismatch / numeric overflow, follow the error's suggestion (typically widen an INT column to BIGINT or NUMERIC(38,0))."
1587    )]
1588    fn load_data(
1589        &self,
1590        Parameters(params): Parameters<LoadDataParams>,
1591    ) -> Result<CallToolResult, rmcp::ErrorData> {
1592        if let Err(e) = self.check_writable("load_data") {
1593            return Self::err_content(e);
1594        }
1595        let table_name = params.table.clone();
1596        // Replace-mode creates the table from scratch (or replaces an
1597        // existing one), which is a resource-list-changing event; append
1598        // mode only changes row content. Captured before the move-into
1599        // closure so we can pick the right notifications after success.
1600        let mode = params.mode.clone().unwrap_or_else(|| "replace".into());
1601        let result = self.with_engine(|engine| {
1602            let target_db =
1603                self.resolve_db(engine, params.database.as_deref(), params.persist, true)?;
1604            let fmt = params.format.unwrap_or_else(|| detect_format(&params.data));
1605            let schema_override = crate::schema::normalize_schema_param(params.schema.as_ref())?;
1606            let opts = IngestOptions {
1607                table: params.table.clone(),
1608                mode: mode.clone(),
1609                schema_override,
1610                merge_key: None,
1611                target_db: target_db.clone(),
1612            };
1613
1614            let ingest_result = match fmt.as_str() {
1615                "csv" => ingest_csv(engine, &params.data, &opts),
1616                _ => ingest_json(engine, &params.data, &opts),
1617            }?;
1618
1619            let schema_json: Vec<Value> = ingest_result
1620                .schema
1621                .iter()
1622                .map(|c| {
1623                    json!({
1624                        "name": c.name,
1625                        "type": c.hyper_type,
1626                        "nullable": c.nullable,
1627                    })
1628                })
1629                .collect();
1630
1631            // Catalog bookkeeping: the helper routes the upsert to
1632            // target_db's per-DB _table_catalog (lazily seeded on
1633            // first ingest). Persistent and ephemeral primary share
1634            // persistent's catalog; user-attached writable DBs each
1635            // get their own.
1636            {
1637                let load_params = serde_json::to_string(&json!({
1638                    "mode": mode,
1639                    "format": fmt,
1640                    "database": target_db.as_deref().unwrap_or("local"),
1641                }))
1642                .ok();
1643                self.after_ingest_catalog_update(
1644                    engine,
1645                    &params.table,
1646                    "load_data",
1647                    load_params.as_deref(),
1648                    i64::try_from(ingest_result.rows).ok(),
1649                    target_db.as_deref(),
1650                );
1651            }
1652
1653            Ok(json!({
1654                "rows": ingest_result.rows,
1655                "schema": schema_json,
1656                "stats": ingest_result.stats.to_json(),
1657            }))
1658        });
1659
1660        match result {
1661            Ok(val) => {
1662                self.notify_table_changed(&table_name);
1663                if mode == "replace" {
1664                    // Replace either created a new table or recreated an
1665                    // existing one — either way the resource catalog
1666                    // moved.
1667                    self.notify_resource_list_changed();
1668                }
1669                Self::ok_content(val)
1670            }
1671            Err(e) => Self::err_content(e),
1672        }
1673    }
1674
1675    /// Load a file (CSV, JSON, JSONL, Parquet, Arrow IPC) into a named workspace table.
1676    #[tool(
1677        description = "Load a CSV / JSON / JSONL / NDJSON / Parquet / Arrow IPC file into a named workspace table. Format is auto-detected from extension (or content for JSON vs CSV).\n\nWhen choosing a format for *new* data going into Hyper, prefer in this order:\n  1. **Parquet** (fastest, server-side): hyperd reads the file directly via `external()`. Types, NUMERIC precision, DATE / TIMESTAMP, and Snappy/ZSTD compression all preserved. This is the recommended format for large imports.\n  2. **CSV**: server-side `COPY FROM` — also fast, but types are inferred from a header + full-file numeric widening pass (CSV has no embedded type info), and empty unquoted cells load as SQL NULL per PostgreSQL CSV default.\n  3. **Arrow IPC** (.arrow / .ipc / .feather, File or Stream format, auto-detected): read in Rust and streamed into hyperd via the binary COPY protocol with zero value-level decoding. Fast but not quite as fast as Parquet, and schema overrides are rejected (the Arrow schema is authoritative).\n  4. **JSON / JSONL / NDJSON**: parsed in Rust (hyperd has no native JSON reader), with per-row insertion. Use for small / irregular data; large JSON should be converted to Parquet first.\n\nFor Apache Iceberg tables use `load_iceberg` instead — it takes a directory path rather than a single file.\n\nSupports partial `schema` overrides keyed by column name (`{\"col\":\"BIGINT\"}`) — only list columns you want to correct; unlisted columns keep their inferred type. Overrides are supported for Parquet, CSV, and JSON; rejected for Arrow IPC. Call `inspect_file` first when unsure about types or to debug a prior failure; the inspector reports per-column min/max/null_count using the exact same inference logic. Use `json_extract_path` to extract a nested data array from a JSON wrapper file — dot-separated path, numeric segments index into arrays, string values are parsed as JSON.\n\n**Mode**: `replace` (default — drops + recreates the table), `append` (adds rows to an existing table), or `merge` (upserts rows by `merge_key`). In merge mode, set `merge_key` to a column name (`\"job_id\"`) or list of names (`[\"cell\",\"job_id\"]`); rows with a matching key are replaced, rows with no match are inserted. New columns in the incoming file are auto-added via `ALTER TABLE ADD COLUMN`. Type changes on existing columns are rejected — use `replace` for breaking schema changes."
1678    )]
1679    fn load_file(
1680        &self,
1681        Parameters(params): Parameters<LoadFileParams>,
1682    ) -> Result<CallToolResult, rmcp::ErrorData> {
1683        if let Err(e) = self.check_writable("load_file") {
1684            return Self::err_content(e);
1685        }
1686        let table_name = params.table.clone();
1687        let mode = params.mode.clone().unwrap_or_else(|| "replace".into());
1688        // Validate (mode, merge_key) combination at the tool boundary so the
1689        // ingest layer can stay focused on the load mechanics. `merge_key`
1690        // is required for merge and rejected for replace/append.
1691        let merge_key_vec = match validate_merge_args(&mode, params.merge_key) {
1692            Ok(v) => v,
1693            Err(e) => return Self::err_content(e),
1694        };
1695        // The closure returns `(payload, schema_changed)` so the
1696        // notify branch below can fire correctly for merges that ran
1697        // an `ALTER TABLE ADD COLUMN`. `replace` always changes shape;
1698        // `merge` only does conditionally; `append` never does.
1699        let result = self.with_engine(|engine| {
1700            let target_db =
1701                self.resolve_db(engine, params.database.as_deref(), params.persist, true)?;
1702            crate::attach::validate_input_path(&params.path, "data file")?;
1703            let schema_override = crate::schema::normalize_schema_param(params.schema.as_ref())?;
1704            let opts = IngestOptions {
1705                table: params.table.clone(),
1706                mode: mode.clone(),
1707                schema_override,
1708                merge_key: merge_key_vec.clone(),
1709                target_db: target_db.clone(),
1710            };
1711
1712            let ingest_result = if let Some(ref json_path) = params.json_extract_path {
1713                let raw = std::fs::read_to_string(&params.path).map_err(|e| {
1714                    McpError::new(
1715                        ErrorCode::FileNotFound,
1716                        format!("Cannot read file '{}': {e}", params.path),
1717                    )
1718                })?;
1719                let extracted = crate::ingest::extract_json_path(&raw, json_path)?;
1720                let array_text = crate::ingest::normalize_json_or_jsonl(&extracted)?;
1721                let mut result = ingest_json(engine, &array_text, &opts)?;
1722                result.stats.operation = "load_file".into();
1723                result.stats.bytes_read = std::fs::metadata(&params.path).map_or(0, |m| m.len());
1724                result.stats.file_format = Some("json".into());
1725                result
1726            } else {
1727                match detect_file_format(std::path::Path::new(&params.path)) {
1728                    InferredFileFormat::Parquet => ingest_parquet_file(engine, &params.path, &opts),
1729                    InferredFileFormat::ArrowIpc => {
1730                        ingest_arrow_ipc_file(engine, &params.path, &opts)
1731                    }
1732                    InferredFileFormat::Json => ingest_json_file(engine, &params.path, &opts),
1733                    InferredFileFormat::Csv => ingest_csv_file(engine, &params.path, &opts),
1734                }?
1735            };
1736
1737            // Capture the schema-changed flag before consuming
1738            // `ingest_result` so the closure can return it alongside
1739            // the JSON payload.
1740            let schema_changed = ingest_result.stats.schema_changed;
1741
1742            let schema_json: Vec<Value> = ingest_result
1743                .schema
1744                .iter()
1745                .map(|c| {
1746                    json!({
1747                        "name": c.name,
1748                        "type": c.hyper_type,
1749                        "nullable": c.nullable,
1750                    })
1751                })
1752                .collect();
1753
1754            // Catalog: helper routes to target_db's per-DB catalog.
1755            {
1756                let load_params = serde_json::to_string(&json!({
1757                    "source_path": params.path,
1758                    "mode": mode,
1759                    "schema": params.schema,
1760                    "json_extract_path": params.json_extract_path,
1761                    "merge_key": merge_key_vec,
1762                    "database": target_db.as_deref().unwrap_or("local"),
1763                }))
1764                .ok();
1765                self.after_ingest_catalog_update(
1766                    engine,
1767                    &params.table,
1768                    "load_file",
1769                    load_params.as_deref(),
1770                    i64::try_from(ingest_result.rows).ok(),
1771                    target_db.as_deref(),
1772                );
1773            }
1774
1775            Ok((
1776                json!({
1777                    "rows": ingest_result.rows,
1778                    "schema": schema_json,
1779                    "stats": ingest_result.stats.to_json(),
1780                }),
1781                schema_changed,
1782            ))
1783        });
1784
1785        match result {
1786            Ok((val, schema_changed)) => {
1787                self.notify_table_changed(&table_name);
1788                // Notify when the resource list's *shape* actually
1789                // changed: `replace` always (table dropped/recreated),
1790                // and `merge` only when it ran an `ALTER TABLE ADD
1791                // COLUMN` (or created the target via the rename short-
1792                // circuit). A merge that only updated existing rows
1793                // leaves the schema untouched, so we skip the
1794                // broadcast — same precedent as `append`.
1795                if mode == "replace" || schema_changed {
1796                    self.notify_resource_list_changed();
1797                }
1798                Self::ok_content(val)
1799            }
1800            Err(e) => Self::err_content(e),
1801        }
1802    }
1803
1804    /// Ingest multiple files in parallel across a pool of async connections.
1805    /// Each entry behaves like a standalone `load_file` call; failures are
1806    /// reported per-file rather than aborting the whole batch.
1807    #[tool(
1808        description = "Ingest multiple files in parallel. Each entry is equivalent to a standalone `load_file` call (same formats and same format-selection guidance: prefer Parquet > CSV > Arrow IPC > JSON for large imports). The batch runs across a pool of async connections sized by `concurrency` (default `min(files.len(), 8)`), so independent files finish roughly in max-time rather than sum-time. Per-file errors are captured in the response and do not abort the rest of the batch; the top-level call still returns Ok. For Apache Iceberg tables, call `load_iceberg` per table instead — this tool only handles single-file formats.\n\nUse `database` (or shorthand `persist: true`) to target a non-primary database; the same value applies to every entry in the batch. **Note: `mode = \"merge\"` is not supported here — use `load_file` once per file when you need merge/upsert semantics.**"
1809    )]
1810    fn load_files(
1811        &self,
1812        Parameters(params): Parameters<LoadFilesParams>,
1813    ) -> Result<CallToolResult, rmcp::ErrorData> {
1814        use hyperdb_api::pool::{create_pool, PoolConfig};
1815        use hyperdb_api::CreateMode;
1816
1817        if let Err(e) = self.check_writable("load_files") {
1818            return Self::err_content(e);
1819        }
1820        if params.files.is_empty() {
1821            return Self::err_content(McpError::new(
1822                ErrorCode::EmptyData,
1823                "load_files: `files` must not be empty",
1824            ));
1825        }
1826
1827        // Reject `mode = "merge"` (or stray `merge_key`) up front, before
1828        // we spin up the connection pool and dispatch the parallel batch.
1829        // The async ingest paths driven from this batch loader don't
1830        // carry the merge-via-temp-table branch, and rejecting per-entry
1831        // would produce a confusing N-rejection result for a uniform
1832        // merge call. One top-level error is the clearer contract.
1833        for (idx, entry) in params.files.iter().enumerate() {
1834            if let Err(mut e) = crate::attach::validate_input_path(&entry.path, "data file") {
1835                e.message = format!("entry {idx} (table '{}'): {}", entry.table, e.message);
1836                return Self::err_content(e);
1837            }
1838            let mode = entry.mode.as_deref().unwrap_or("replace");
1839            if mode == "merge" || entry.merge_key.is_some() {
1840                return Self::err_content(McpError::new(
1841                    ErrorCode::InvalidArgument,
1842                    format!(
1843                        "load_files does not support mode=merge yet (entry {idx}, table \
1844                         '{}'). Call load_file once per file when you need merge semantics.",
1845                        entry.table
1846                    ),
1847                ));
1848            }
1849        }
1850
1851        // Resolve hyperd endpoint + the workspace path matching the
1852        // resolved target database. The pool opens that .hyper file
1853        // directly (under the same alias the engine uses) so qualified
1854        // SQL routes correctly. Read-only attachments are rejected by
1855        // resolve_db.
1856        let (endpoint, workspace, target_db) = match self.with_engine(|engine| {
1857            let target_db =
1858                self.resolve_db(engine, params.database.as_deref(), params.persist, true)?;
1859            let endpoint = engine.hyperd_endpoint()?;
1860            let workspace = match target_db.as_deref() {
1861                None => engine.ephemeral_path().to_string_lossy().to_string(),
1862                Some(alias) if alias.eq_ignore_ascii_case(Engine::PERSISTENT_ALIAS) => engine
1863                    .persistent_path()
1864                    .ok_or_else(|| {
1865                        McpError::new(
1866                            ErrorCode::InvalidArgument,
1867                            "target 'persistent' but the server is in --ephemeral-only mode",
1868                        )
1869                    })?
1870                    .to_string_lossy()
1871                    .to_string(),
1872                Some(alias) => {
1873                    let entry = self.attachments.get(alias).ok_or_else(|| {
1874                        McpError::new(
1875                            ErrorCode::InvalidArgument,
1876                            format!("database '{alias}' is not attached"),
1877                        )
1878                    })?;
1879                    let crate::attach::AttachSource::LocalFile { path } = &entry.source;
1880                    path.to_string_lossy().to_string()
1881                }
1882            };
1883            Ok((endpoint, workspace, target_db))
1884        }) {
1885            Ok(v) => v,
1886            Err(e) => return Self::err_content(e),
1887        };
1888
1889        // Pool size: cap at files.len() and an absolute ceiling of 16 to
1890        // avoid starving the primary connection hyperd is already servicing.
1891        let file_count = params.files.len();
1892        let concurrency = params
1893            .concurrency
1894            .map_or(8, |n| n as usize)
1895            .min(file_count)
1896            .clamp(1, 16);
1897
1898        let pool = match create_pool(
1899            PoolConfig::new(endpoint, workspace)
1900                .create_mode(CreateMode::DoNotCreate)
1901                .max_size(concurrency),
1902        ) {
1903            Ok(p) => Arc::new(p),
1904            Err(e) => {
1905                return Self::err_content(McpError::new(
1906                    ErrorCode::InternalError,
1907                    format!("Failed to build connection pool for load_files: {e}"),
1908                ))
1909            }
1910        };
1911
1912        // Drive the async fan-out from this sync tool handler using the
1913        // same pattern as `start_watching`: block_in_place + block_on.
1914        let Ok(rt) = tokio::runtime::Handle::try_current() else {
1915            return Self::err_content(McpError::new(
1916                ErrorCode::InternalError,
1917                "load_files must run inside a tokio runtime",
1918            ));
1919        };
1920
1921        // Per-entry result payload. Successful entries carry rows/schema/stats;
1922        // failures carry error code + message. Order matches input `files`.
1923        #[derive(Default)]
1924        struct EntryOutcome {
1925            table: String,
1926            ok: Option<(u64, Vec<Value>, Value)>,
1927            err: Option<(ErrorCode, String)>,
1928            replace_mode: bool,
1929        }
1930
1931        let outcomes: Vec<EntryOutcome> = tokio::task::block_in_place(|| {
1932            rt.block_on(async {
1933                let mut set = tokio::task::JoinSet::new();
1934                for (idx, entry) in params.files.into_iter().enumerate() {
1935                    let pool = Arc::clone(&pool);
1936                    let entry_target_db = target_db.clone();
1937                    set.spawn(async move {
1938                        let mode = entry.mode.clone().unwrap_or_else(|| "replace".into());
1939                        let replace_mode = mode == "replace";
1940                        let mut out = EntryOutcome {
1941                            table: entry.table.clone(),
1942                            replace_mode,
1943                            ..Default::default()
1944                        };
1945
1946                        // `merge` mode is rejected up front in the
1947                        // top-level handler; per-entry guard would be
1948                        // dead code here.
1949
1950                        let schema_override =
1951                            match crate::schema::normalize_schema_param(entry.schema.as_ref()) {
1952                                Ok(v) => v,
1953                                Err(e) => {
1954                                    out.err = Some((e.code, e.message));
1955                                    return (idx, out);
1956                                }
1957                            };
1958                        // The pool was built against the resolved target's
1959                        // .hyper file as its workspace, so from these
1960                        // connections' perspective the target IS the primary
1961                        // database. Keep target_db unqualified (None) so SQL
1962                        // routes into the pool's primary instead of trying
1963                        // to qualify against an alias that doesn't exist on
1964                        // these connections. The `entry_target_db` is still
1965                        // used downstream for the catalog gate.
1966                        let _ = &entry_target_db;
1967                        let opts = IngestOptions {
1968                            table: entry.table.clone(),
1969                            mode: mode.clone(),
1970                            schema_override,
1971                            merge_key: None,
1972                            target_db: None,
1973                        };
1974
1975                        // Check out a connection from the pool. Held only
1976                        // for the duration of this one ingest, then released.
1977                        let mut conn = match pool.get().await {
1978                            Ok(c) => c,
1979                            Err(e) => {
1980                                out.err = Some((
1981                                    ErrorCode::InternalError,
1982                                    format!("Failed to check out connection: {e}"),
1983                                ));
1984                                return (idx, out);
1985                            }
1986                        };
1987
1988                        // `json_extract_path` only makes sense for JSON; the
1989                        // sync loader wraps the file read + normalize step
1990                        // around `ingest_json`. Mirror that here using the
1991                        // async ingest_json on the pooled connection.
1992                        let ingest_res = if let Some(ref json_path) = entry.json_extract_path {
1993                            let raw = match std::fs::read_to_string(&entry.path) {
1994                                Ok(s) => s,
1995                                Err(e) => {
1996                                    out.err = Some((
1997                                        ErrorCode::FileNotFound,
1998                                        format!("Cannot read file '{}': {e}", entry.path),
1999                                    ));
2000                                    return (idx, out);
2001                                }
2002                            };
2003                            let extracted = match crate::ingest::extract_json_path(&raw, json_path)
2004                            {
2005                                Ok(v) => v,
2006                                Err(e) => {
2007                                    out.err = Some((e.code, e.message));
2008                                    return (idx, out);
2009                                }
2010                            };
2011                            let array_text =
2012                                match crate::ingest::normalize_json_or_jsonl(&extracted) {
2013                                    Ok(v) => v,
2014                                    Err(e) => {
2015                                        out.err = Some((e.code, e.message));
2016                                        return (idx, out);
2017                                    }
2018                                };
2019                            crate::ingest::ingest_json_async(&mut conn, &array_text, &opts)
2020                                .await
2021                                .map(|mut r| {
2022                                    r.stats.operation = "load_file".into();
2023                                    r.stats.bytes_read =
2024                                        std::fs::metadata(&entry.path).map_or(0, |m| m.len());
2025                                    r.stats.file_format = Some("json".into());
2026                                    r
2027                                })
2028                        } else {
2029                            match detect_file_format(std::path::Path::new(&entry.path)) {
2030                                InferredFileFormat::Parquet => {
2031                                    ingest_parquet_file_async(&mut conn, &entry.path, &opts).await
2032                                }
2033                                InferredFileFormat::ArrowIpc => {
2034                                    ingest_arrow_ipc_file_async(&mut conn, &entry.path, &opts).await
2035                                }
2036                                InferredFileFormat::Json => {
2037                                    ingest_json_file_async(&mut conn, &entry.path, &opts).await
2038                                }
2039                                InferredFileFormat::Csv => {
2040                                    ingest_csv_file_async(&mut conn, &entry.path, &opts).await
2041                                }
2042                            }
2043                        };
2044
2045                        match ingest_res {
2046                            Ok(r) => {
2047                                let schema_json: Vec<Value> = r
2048                                    .schema
2049                                    .iter()
2050                                    .map(|c| {
2051                                        json!({
2052                                            "name": c.name,
2053                                            "type": c.hyper_type,
2054                                            "nullable": c.nullable,
2055                                        })
2056                                    })
2057                                    .collect();
2058                                out.ok = Some((r.rows, schema_json, r.stats.to_json()));
2059                            }
2060                            Err(e) => {
2061                                out.err = Some((e.code, e.message));
2062                            }
2063                        }
2064
2065                        (idx, out)
2066                    });
2067                }
2068
2069                // Preserve input order when flattening the join set so the
2070                // response mirrors the caller's `files` array 1-for-1.
2071                let mut collected: Vec<Option<EntryOutcome>> =
2072                    (0..file_count).map(|_| None).collect();
2073                while let Some(joined) = set.join_next().await {
2074                    match joined {
2075                        Ok((idx, outcome)) => collected[idx] = Some(outcome),
2076                        Err(e) => {
2077                            // A task panicked — surface it as an error on a
2078                            // synthetic slot so the caller sees something.
2079                            tracing::warn!("load_files task join error: {e}");
2080                        }
2081                    }
2082                }
2083                collected.into_iter().flatten().collect()
2084            })
2085        });
2086
2087        // Catalog bookkeeping + notifications for successful loads. Runs
2088        // back on the sync engine connection. Best-effort; errors are
2089        // logged but don't fail the batch response.
2090        let mut any_replace_succeeded = false;
2091        let mut tables_to_notify: Vec<String> = Vec::new();
2092        let results_json: Vec<Value> = outcomes
2093            .iter()
2094            .map(|o| match (&o.ok, &o.err) {
2095                (Some((rows, schema, stats)), _) => {
2096                    tables_to_notify.push(o.table.clone());
2097                    if o.replace_mode {
2098                        any_replace_succeeded = true;
2099                    }
2100                    json!({
2101                        "table": o.table,
2102                        "rows": rows,
2103                        "schema": schema,
2104                        "stats": stats,
2105                    })
2106                }
2107                (None, Some((code, msg))) => json!({
2108                    "table": o.table,
2109                    "error": {
2110                        "code": format!("{:?}", code),
2111                        "message": msg,
2112                    }
2113                }),
2114                // Shouldn't happen (exactly one of ok/err is set) but be
2115                // defensive — emit a placeholder rather than panicking.
2116                (None, None) => json!({
2117                    "table": o.table,
2118                    "error": {
2119                        "code": "InternalError",
2120                        "message": "load_files task produced no outcome",
2121                    }
2122                }),
2123            })
2124            .collect();
2125
2126        // Update the per-table catalog stubs for every success. Requires
2127        // the engine, so we run this inside `with_engine`. The helper
2128        // routes the upsert to target_db's per-DB _table_catalog.
2129        if let Err(e) = self.with_engine(|engine| {
2130            for o in &outcomes {
2131                if let Some((rows, _, _)) = &o.ok {
2132                    self.after_ingest_catalog_update(
2133                        engine,
2134                        &o.table,
2135                        "load_file",
2136                        None,
2137                        i64::try_from(*rows).ok(),
2138                        target_db.as_deref(),
2139                    );
2140                }
2141            }
2142            Ok(())
2143        }) {
2144            tracing::warn!("load_files: catalog update batch failed: {}", e.message);
2145        }
2146
2147        for t in &tables_to_notify {
2148            self.notify_table_changed(t);
2149        }
2150        if any_replace_succeeded {
2151            self.notify_resource_list_changed();
2152        }
2153
2154        let success_count = outcomes.iter().filter(|o| o.ok.is_some()).count();
2155        let failure_count = outcomes.len() - success_count;
2156
2157        Self::ok_content(json!({
2158            "results": results_json,
2159            "summary": {
2160                "total": outcomes.len(),
2161                "succeeded": success_count,
2162                "failed": failure_count,
2163                "concurrency": concurrency,
2164            }
2165        }))
2166    }
2167
2168    /// Ingest an Apache Iceberg table directory into a workspace table
2169    /// using hyperd's native `external(..., format => 'iceberg')` reader.
2170    #[tool(
2171        description = "Ingest an Apache Iceberg table into a workspace table using hyperd's native Iceberg reader. `path` must be an absolute path to the Iceberg table *root directory* (the one containing the `metadata/` and `data/` subdirs). Hyperd resolves the latest snapshot by default; pass `metadata_filename` (e.g. `v2.metadata.json`) or `version_as_of` to pin a specific snapshot or version. Mode is `replace` (default) or `append`. Single SQL statement under the hood — no Rust-side Arrow decode, no per-row INSERTs."
2172    )]
2173    fn load_iceberg(
2174        &self,
2175        Parameters(params): Parameters<LoadIcebergParams>,
2176    ) -> Result<CallToolResult, rmcp::ErrorData> {
2177        if let Err(e) = self.check_writable("load_iceberg") {
2178            return Self::err_content(e);
2179        }
2180        let table_name = params.table.clone();
2181        let mode = params.mode.clone().unwrap_or_else(|| "replace".into());
2182        let opts = crate::lakehouse::IcebergIngestOptions {
2183            table: params.table.clone(),
2184            mode: mode.clone(),
2185            metadata_filename: params.metadata_filename.clone(),
2186            version_as_of: params.version_as_of,
2187        };
2188
2189        let result = self.with_engine(|engine| {
2190            // Iceberg "path" is a directory, not a file — validate as input path.
2191            crate::attach::validate_input_path(&params.path, "iceberg table")?;
2192            let ingest_result =
2193                crate::lakehouse::ingest_iceberg_table(engine, &params.path, &opts)?;
2194
2195            let schema_json: Vec<Value> = ingest_result
2196                .schema
2197                .iter()
2198                .map(|c| {
2199                    json!({
2200                        "name": c.name,
2201                        "type": c.hyper_type,
2202                        "nullable": c.nullable,
2203                    })
2204                })
2205                .collect();
2206
2207            let load_params = serde_json::to_string(&json!({
2208                "source_path": params.path,
2209                "mode": mode,
2210                "format": "iceberg",
2211                "metadata_filename": params.metadata_filename,
2212                "version_as_of": params.version_as_of,
2213            }))
2214            .ok();
2215            self.after_ingest_catalog_update(
2216                engine,
2217                &params.table,
2218                "load_iceberg",
2219                load_params.as_deref(),
2220                i64::try_from(ingest_result.rows).ok(),
2221                None,
2222            );
2223
2224            Ok(json!({
2225                "rows": ingest_result.rows,
2226                "schema": schema_json,
2227                "stats": ingest_result.stats.to_json(),
2228            }))
2229        });
2230
2231        match result {
2232            Ok(val) => {
2233                self.notify_table_changed(&table_name);
2234                if mode == "replace" {
2235                    self.notify_resource_list_changed();
2236                }
2237                Self::ok_content(val)
2238            }
2239            Err(e) => Self::err_content(e),
2240        }
2241    }
2242
2243    /// Run a read-only SQL query (SELECT, WITH, EXPLAIN, SHOW, VALUES).
2244    #[tool(
2245        description = "Run a read-only SQL query (SELECT, WITH, EXPLAIN, SHOW, VALUES) against the workspace. For DDL/DML use the execute tool."
2246    )]
2247    fn query(
2248        &self,
2249        Parameters(params): Parameters<QueryParams>,
2250    ) -> Result<CallToolResult, rmcp::ErrorData> {
2251        let result = self.with_engine(|engine| {
2252            if !is_read_only_sql(&params.sql) {
2253                return Err(McpError::new(
2254                    ErrorCode::SqlError,
2255                    "The query tool only accepts read-only SQL (SELECT, WITH, EXPLAIN, SHOW, VALUES). Use the execute tool for DDL/DML.",
2256                ));
2257            }
2258            // Optional database routing — temporarily redirect search_path
2259            // for the duration of this call. Restored on guard drop.
2260            let target_db = self.resolve_db(engine, params.database.as_deref(), None, false)?;
2261            let _search_guard = match target_db {
2262                Some(ref alias) => Some(engine.scoped_search_path(alias)?),
2263                None => None,
2264            };
2265            // Cap result-set size sent back to the LLM. Larger result sets blow
2266            // the model's context window and stall the conversation. Users who
2267            // need full scans should use `export` to write to a file.
2268            const MAX_QUERY_ROWS: usize = 10_000;
2269
2270            let timer = crate::stats::StatsTimer::start();
2271            let mut rows = engine.execute_query_to_json(&params.sql)?;
2272            let total_rows = rows.len();
2273            let truncated = total_rows > MAX_QUERY_ROWS;
2274            if truncated {
2275                rows.truncate(MAX_QUERY_ROWS);
2276            }
2277            let elapsed = timer.elapsed_ms();
2278            let stats = crate::stats::QueryStats {
2279                operation: "query".into(),
2280                rows_returned: rows.len() as u64,
2281                rows_scanned: 0,
2282                elapsed_ms: elapsed,
2283                result_size_bytes: serde_json::to_string(&rows).map_or(0, |s| s.len() as u64),
2284                tables_touched: vec![],
2285            };
2286            let payload = if truncated {
2287                json!({
2288                    "result": rows,
2289                    "stats": stats.to_json(),
2290                    "truncated": true,
2291                    "total_rows": total_rows,
2292                    "rows_returned": MAX_QUERY_ROWS,
2293                    "hint": format!(
2294                        "Result set has {total_rows} rows; only the first {MAX_QUERY_ROWS} \
2295                         are shown. Add a LIMIT clause, aggregate with GROUP BY, or use \
2296                         the `export` tool to write the full result to a file."
2297                    ),
2298                })
2299            } else {
2300                json!({
2301                    "result": rows,
2302                    "stats": stats.to_json(),
2303                })
2304            };
2305            Ok((params.sql.clone(), payload))
2306        });
2307
2308        match result {
2309            Ok((sql, val)) => {
2310                let formatted_sql = Self::fmt_sql(&sql);
2311                let json_text = serde_json::to_string_pretty(&val).unwrap_or_default();
2312                Ok(CallToolResult::success(vec![
2313                    Content::text(format!("```sql\n{formatted_sql}\n```")),
2314                    Content::text(json_text),
2315                ]))
2316            }
2317            Err(e) => Self::err_content(e),
2318        }
2319    }
2320
2321    /// Execute one or more DDL/DML statements as an atomic batch.
2322    #[tool(
2323        description = "Execute one or more DDL/DML statements as an atomic batch. `sql` is an array of statements; pass `[\"SQL\"]` for a single statement or `[\"UPDATE …\", \"INSERT …\"]` for an atomic upsert. Multi-statement batches run inside a transaction — if any statement fails, all are rolled back. Mixing DDL with DML in one batch is rejected (Hyper aborts such transactions). Disabled in read-only mode."
2324    )]
2325    fn execute(
2326        &self,
2327        Parameters(params): Parameters<ExecuteParams>,
2328    ) -> Result<CallToolResult, rmcp::ErrorData> {
2329        if let Err(e) = self.check_writable("execute") {
2330            return Self::err_content(e);
2331        }
2332        // Validation runs outside `with_engine` so a malformed batch
2333        // doesn't tie up an engine handle. All checks short-circuit on
2334        // the first failure with an InvalidArgument / SqlError carrying
2335        // an LLM-actionable suggestion.
2336        if let Err(e) = validate_execute_batch(&params.sql) {
2337            return Self::err_content(e);
2338        }
2339        let any_structural = params
2340            .sql
2341            .iter()
2342            .any(|s| matches!(classify_statement(s), StatementKind::Ddl));
2343        let result = self.with_engine(|engine| {
2344            // Optional database routing — temporarily redirect search_path.
2345            // require_writable=true ensures non-primary aliases must be writable.
2346            // Held for the entire batch (and transaction, if multi-statement).
2347            let target_db = self.resolve_db(engine, params.database.as_deref(), None, true)?;
2348            let _search_guard = match target_db {
2349                Some(ref alias) => Some(engine.scoped_search_path(alias)?),
2350                None => None,
2351            };
2352            let total_timer = crate::stats::StatsTimer::start();
2353            let (per_statement, affected_total, operation): (Vec<Value>, u64, &'static str) =
2354                if params.sql.len() == 1 {
2355                    // Singletons skip BEGIN/COMMIT — same auto-commit behavior
2356                    // as the pre-batch `execute` tool, and DDL singletons stay
2357                    // legal (Hyper auto-commits DDL anyway).
2358                    let stmt = &params.sql[0];
2359                    let t = crate::stats::StatsTimer::start();
2360                    let affected = engine.execute_command(stmt)?;
2361                    (
2362                        vec![json!({
2363                            "sql": Self::fmt_sql(stmt),
2364                            "affected_rows": affected,
2365                            "elapsed_ms": t.elapsed_ms(),
2366                        })],
2367                        affected,
2368                        "command",
2369                    )
2370                } else {
2371                    let stmts = &params.sql;
2372                    let (results, total) = engine.execute_in_transaction(|engine| {
2373                        let mut out = Vec::with_capacity(stmts.len());
2374                        let mut total: u64 = 0;
2375                        for (idx, stmt) in stmts.iter().enumerate() {
2376                            let t = crate::stats::StatsTimer::start();
2377                            let affected = engine.execute_command(stmt).map_err(|e| {
2378                                // Preserve the original error's code AND its
2379                                // suggestion (e.g. Hyper's "did you mean
2380                                // <column>?") — append the rollback context
2381                                // rather than replacing it.
2382                                let rollback_note = format!(
2383                                    "Failing SQL: {sql}. All previous statements in this batch were rolled back.",
2384                                    sql = Self::fmt_sql(stmt)
2385                                );
2386                                let combined = match e.suggestion.as_deref() {
2387                                    Some(orig) => format!("{orig} | {rollback_note}"),
2388                                    None => rollback_note,
2389                                };
2390                                McpError::new(
2391                                    e.code,
2392                                    format!(
2393                                        "statement {} of {} failed: {}",
2394                                        idx + 1,
2395                                        stmts.len(),
2396                                        e.message
2397                                    ),
2398                                )
2399                                .with_suggestion(combined)
2400                            })?;
2401                            // saturating_add: a single batch summing to >2^64 rows
2402                            // is implausible, but clamp rather than wrap on the
2403                            // off chance — wrap-around would silently undercount.
2404                            total = total.saturating_add(affected);
2405                            out.push(json!({
2406                                "sql": Self::fmt_sql(stmt),
2407                                "affected_rows": affected,
2408                                "elapsed_ms": t.elapsed_ms(),
2409                            }));
2410                        }
2411                        Ok((out, total))
2412                    })?;
2413                    (results, total, "transaction")
2414                };
2415            let elapsed = total_timer.elapsed_ms();
2416            // Reconcile only when the batch contains a statement that
2417            // could have changed the set of tables (CREATE / DROP /
2418            // ALTER / TRUNCATE / RENAME). Pure DML can't add or remove
2419            // tables, so running `reconcile_in` on every row-level
2420            // execute would do `2N + 2` SQL round-trips of pure waste.
2421            // Same gate as `notify_resource_list_changed` below; both
2422            // fire on the same set of statements.
2423            //
2424            // Threads `target_db` so a structural DDL against a
2425            // user-attached alias also reconciles that DB's catalog
2426            // (otherwise the dropped table's row stays stranded —
2427            // bootstrap reconcile only walks persistent).
2428            if any_structural {
2429                self.after_execute_catalog_update(engine, target_db.as_deref());
2430            }
2431            Ok(json!({
2432                "statements": per_statement.len(),
2433                "affected_rows": affected_total,
2434                "per_statement": per_statement,
2435                "stats": { "operation": operation, "elapsed_ms": elapsed },
2436            }))
2437        });
2438
2439        match result {
2440            Ok(val) => {
2441                // Arbitrary DDL/DML may have touched any table — fire the
2442                // workspace-wide summary updates, and a list_changed to
2443                // nudge subscribers to refresh their resource catalog for
2444                // CREATE / DROP style statements.
2445                self.notify_workspace_changed();
2446                if any_structural {
2447                    self.notify_resource_list_changed();
2448                }
2449                Self::ok_content(val)
2450            }
2451            Err(e) => Self::err_content(e),
2452        }
2453    }
2454
2455    /// Return the schema, total row count, and the first N rows of a table.
2456    #[tool(
2457        description = "Return the schema, total row count, and first N rows of a table. Combines describe + sample query in one call. N defaults to 5, max 100."
2458    )]
2459    fn sample(
2460        &self,
2461        Parameters(params): Parameters<SampleParams>,
2462    ) -> Result<CallToolResult, rmcp::ErrorData> {
2463        let result = self.with_engine(|engine| {
2464            let target_db = self.resolve_db(engine, params.database.as_deref(), None, false)?;
2465            let timer = crate::stats::StatsTimer::start();
2466            let n = params.n.unwrap_or(5);
2467            let mut sample = engine.sample_table_in(target_db.as_deref(), &params.table, n)?;
2468            let elapsed = timer.elapsed_ms();
2469            if let Some(obj) = sample.as_object_mut() {
2470                obj.insert(
2471                    "stats".into(),
2472                    json!({ "operation": "sample", "elapsed_ms": elapsed }),
2473                );
2474            }
2475            Ok(sample)
2476        });
2477
2478        match result {
2479            Ok(val) => Self::ok_content(val),
2480            Err(e) => Self::err_content(e),
2481        }
2482    }
2483
2484    /// Render a chart (PNG or SVG) from a SQL query.
2485    #[tool(
2486        description = "Render a chart (bar, line, scatter, or histogram) from a SQL query. Returns the PNG/SVG image inline by default so MCP clients can display it directly. Set `inline=false` to skip the inline bytes and write to disk only (keeps the MCP transcript small for batch workflows). Combine `inline=true` with `output_path` to get both.\n\n**Data shape:** The query must return long-format data with one numeric `y` column. For multi-series charts, use a `series` column to split by category. If your data is wide-format (multiple value columns), reshape it with `UNION ALL` into (label, series, value) tuples before charting.\n\n**DATE/TIMESTAMP x-axis:** Line and scatter charts auto-detect non-numeric x columns. DATE, TIMESTAMP, and TIMESTAMPTZ values render with a **proportional time axis** — gaps between data points reflect real wall-clock time (4.5 h gap and 17 h gap don't look the same). Tick labels are formatted in the input kind: `%Y-%m-%d` for DATE, `%Y-%m-%d %H:%M:%S` for TIMESTAMP, with the originating timezone offset preserved for TIMESTAMPTZ. TEXT x columns fall back to evenly-spaced categorical mode. Set `x_as_category: true` to force categorical layout on temporal data (useful when even spacing reads better than proportional gaps).\n\n- `output_path`: explicit destination file path. Parent directory is created automatically (no need to pre-create it). If omitted and `inline=true` (default), no file is written. If omitted and `inline=false`, a file is auto-generated under the system temp dir as `hyperdb-charts/chart-<ts>-<pid>-<n>.<ext>`.\n- `inline`: when true (default), return the image bytes inline. Without `output_path`, suppresses the disk write entirely. With `output_path`, writes to disk AND returns inline. Set to false for disk-only output.\n- `format`: \"png\" (default) or \"svg\". Auto-derived from `output_path` extension when omitted. A mismatch between `format` and the path extension returns `INVALID_ARGUMENT`.\n- `overwrite`: default true. Set false to refuse overwriting an existing file (returns `PERMISSION_DENIED`).\n- `x_range` / `y_range`: fix axis extents across multiple charts (e.g. x_range=[0,1500], y_range=[0,1]).\n- `color_map`: stable per-series hex colors (e.g. {\"India\":\"#e41a1c\",\"China\":\"#ff7f0e\"}).\n- `label_points=true`: annotate each point with its series name instead of showing a legend — best when each series has exactly one point."
2487    )]
2488    fn chart(
2489        &self,
2490        Parameters(params): Parameters<ChartParams>,
2491    ) -> Result<CallToolResult, rmcp::ErrorData> {
2492        let result = self.with_engine(|engine| {
2493            if !is_read_only_sql(&params.sql) {
2494                return Err(McpError::new(
2495                    ErrorCode::SqlError,
2496                    "The chart tool only accepts read-only SQL (SELECT, WITH, EXPLAIN, SHOW, VALUES).",
2497                ));
2498            }
2499
2500            // If the caller passed an explicit output path, validate it.
2501            // Auto-generated paths land in a temp dir and don't need this gate.
2502            if let Some(out) = params.output_path.as_deref() {
2503                crate::attach::validate_output_path(out, "chart output")?;
2504            }
2505            // Resolve format up front — the path extension may imply it,
2506            // and we need the format before we can auto-generate a path.
2507            let format = crate::chart::resolve_chart_format(
2508                params.format.as_deref(),
2509                params.output_path.as_deref(),
2510            )?;
2511
2512            // Optional database routing — temporarily redirect search_path
2513            // so unqualified names in the chart SQL resolve there.
2514            let target_db = self.resolve_db(engine, params.database.as_deref(), None, false)?;
2515            let _search_guard = match target_db {
2516                Some(ref alias) => Some(engine.scoped_search_path(alias)?),
2517                None => None,
2518            };
2519
2520            let timer = crate::stats::StatsTimer::start();
2521            let rows = engine.execute_query_to_json(&params.sql)?;
2522
2523            // Parse color_map: skip entries whose hex string is malformed,
2524            // logging them via the description rather than hard-failing.
2525            let color_map = params
2526                .color_map
2527                .as_ref()
2528                .map(|m| {
2529                    m.iter()
2530                        .filter_map(|(k, v)| {
2531                            crate::chart::parse_hex_color(v)
2532                                .map(|c| (k.clone(), c))
2533                        })
2534                        .collect::<std::collections::HashMap<_, _>>()
2535                })
2536                .unwrap_or_default();
2537
2538            let opts = ChartOptions {
2539                chart_type: ChartType::parse(&params.chart_type)?,
2540                x_column: params.x.clone(),
2541                y_column: params.y.clone(),
2542                series_column: params.series.clone(),
2543                title: params.title.clone(),
2544                format,
2545                width: params.width.unwrap_or(800).clamp(200, 4096),
2546                height: params.height.unwrap_or(480).clamp(150, 4096),
2547                bins: params.bins.unwrap_or(20).clamp(1, 500),
2548                x_as_category: params.x_as_category,
2549                x_range: params.x_range,
2550                y_range: params.y_range,
2551                color_map,
2552                label_points: params.label_points.unwrap_or(false),
2553            };
2554
2555            let chart = render_chart(&rows, &opts)?;
2556
2557            // Decide disk vs inline vs both. Write to disk *before*
2558            // building the content vec so an I/O failure surfaces as a
2559            // tool error instead of a half-delivered response.
2560            let disposition = crate::chart::resolve_chart_disposition(
2561                params.inline.unwrap_or(true),
2562                params.output_path.as_deref(),
2563                opts.format,
2564            );
2565            let overwrite = params.overwrite.unwrap_or(true);
2566            if let Some(path) = disposition.path() {
2567                crate::chart::write_chart_to_disk(path, &chart.bytes, overwrite)?;
2568            }
2569
2570            let elapsed = timer.elapsed_ms();
2571            Ok((chart, elapsed, opts, disposition))
2572        });
2573
2574        match result {
2575            Ok((chart, elapsed_ms, opts, disposition)) => {
2576                let format_str = match opts.format {
2577                    ChartFormat::Png => "png",
2578                    ChartFormat::Svg => "svg",
2579                };
2580                let wants_inline = disposition.wants_inline();
2581                let output_path_str = disposition.path().map(|p| p.to_string_lossy().into_owned());
2582
2583                let mut stats = serde_json::Map::new();
2584                stats.insert("operation".into(), json!("chart"));
2585                stats.insert("rows_plotted".into(), json!(chart.rows_plotted));
2586                stats.insert("elapsed_ms".into(), json!(elapsed_ms));
2587                stats.insert("format".into(), json!(format_str));
2588                stats.insert("bytes".into(), json!(chart.bytes.len()));
2589                stats.insert("width".into(), json!(opts.width));
2590                stats.insert("height".into(), json!(opts.height));
2591                stats.insert("inline".into(), json!(wants_inline));
2592                if let Some(p) = output_path_str {
2593                    stats.insert("output_path".into(), json!(p));
2594                }
2595                let stats_text =
2596                    serde_json::to_string_pretty(&Value::Object(stats)).unwrap_or_default();
2597
2598                let mut content = Vec::with_capacity(2);
2599                if wants_inline {
2600                    let b64 = base64::engine::general_purpose::STANDARD.encode(&chart.bytes);
2601                    content.push(Content::image(b64, chart.mime_type.to_string()));
2602                }
2603                content.push(Content::text(stats_text));
2604                Ok(CallToolResult::success(content))
2605            }
2606            Err(e) => Self::err_content(e),
2607        }
2608    }
2609
2610    /// Begin watching a directory for `.ready` sentinel files. See
2611    /// [`crate::watcher`] for the full producer/consumer protocol.
2612    #[tool(
2613        description = "Watch a directory for files to auto-ingest. Producers write data file + companion <name>.ready sentinel; the watcher appends the data file to the given table and deletes both on success. Use `database` (or shorthand `persist: true`) to target a non-primary database — the watcher's connection pool opens that file directly. `detach_database` rejects while a watcher is active; call `unwatch_directory` first. Disabled in read-only mode."
2614    )]
2615    fn watch_directory(
2616        &self,
2617        Parameters(params): Parameters<WatchDirectoryParams>,
2618    ) -> Result<CallToolResult, rmcp::ErrorData> {
2619        if let Err(e) = self.check_writable("watch_directory") {
2620            return Self::err_content(e);
2621        }
2622        let canonical = match crate::attach::validate_input_path(&params.path, "watch directory") {
2623            Ok(p) => p,
2624            Err(e) => return Self::err_content(e),
2625        };
2626        // Eagerly initialize the engine so the background watcher thread can
2627        // assume `engine.as_ref()` is Some without needing workspace_path.
2628        match self.ensure_engine() {
2629            Ok(guard) => drop(guard),
2630            Err(e) => return Self::err_content(e),
2631        }
2632
2633        // Resolve the target database once, under the engine lock. Read-only
2634        // attachments are rejected here (require_writable=true) so the
2635        // watcher can't be pointed at a destination it can't write to.
2636        let target_db = match self.with_engine(|engine| {
2637            self.resolve_db(engine, params.database.as_deref(), params.persist, true)
2638        }) {
2639            Ok(v) => v,
2640            Err(e) => return Self::err_content(e),
2641        };
2642
2643        let path = canonical;
2644        let engine_handle = self.engine_handle();
2645        let attachments = self.attachments_handle();
2646        let registry = self.watchers_handle();
2647        let options = crate::watcher::WatchOptions {
2648            max_concurrent: params.max_concurrent.unwrap_or(0) as usize,
2649        };
2650        let result = crate::watcher::start_watching(
2651            engine_handle,
2652            attachments,
2653            registry,
2654            Some(self.subscriptions_handle()),
2655            path.clone(),
2656            params.table.clone(),
2657            target_db,
2658            options,
2659        );
2660        match result {
2661            Ok(stats) => {
2662                let body = json!({
2663                    "directory": path.to_string_lossy(),
2664                    "table": params.table,
2665                    "status": "watching",
2666                    "max_concurrent": stats.max_concurrent,
2667                    "initial_sweep": {
2668                        "files_ingested": stats.files_ingested,
2669                        "files_failed": stats.files_failed,
2670                    },
2671                });
2672                Self::ok_content(body)
2673            }
2674            Err(e) => Self::err_content(e),
2675        }
2676    }
2677
2678    /// Stop watching a directory.
2679    #[tool(
2680        description = "Stop watching a directory previously registered with watch_directory. Pending .ready files are left in place."
2681    )]
2682    fn unwatch_directory(
2683        &self,
2684        Parameters(params): Parameters<UnwatchDirectoryParams>,
2685    ) -> Result<CallToolResult, rmcp::ErrorData> {
2686        let path = std::path::PathBuf::from(&params.path);
2687        let result = crate::watcher::stop_watching(&self.watchers_handle(), &path);
2688        match result {
2689            Ok(summary) => Self::ok_content(summary),
2690            Err(e) => Self::err_content(e),
2691        }
2692    }
2693
2694    /// Describe workspace tables. With `table` set, returns just that
2695    /// table's columns and row count; without it, lists every public table.
2696    #[tool(
2697        description = "Describe workspace tables. With `table` set, returns that single table's columns and row count (TABLE_NOT_FOUND if missing). Without `table`, lists every public table."
2698    )]
2699    fn describe(
2700        &self,
2701        Parameters(params): Parameters<DescribeParams>,
2702    ) -> Result<CallToolResult, rmcp::ErrorData> {
2703        let result = self.with_engine(|engine| {
2704            let target_db = self.resolve_db(engine, params.database.as_deref(), None, false)?;
2705            match params.table.as_deref() {
2706                Some(name) => engine
2707                    .describe_table_in(target_db.as_deref(), name)
2708                    .map(|t| vec![t]),
2709                None => engine.describe_tables_in(target_db.as_deref()),
2710            }
2711        });
2712
2713        match result {
2714            Ok(tables) => Self::ok_content(json!({"tables": tables})),
2715            Err(e) => Self::err_content(e),
2716        }
2717    }
2718
2719    /// Dry-run schema inference on a file (CSV, Parquet, Arrow IPC) without
2720    /// ingesting it. Returns the inferred schema plus per-column diagnostics
2721    /// (`null_count`, `min`, `max`, `sample_values`) so an LLM can construct
2722    /// a safer `schema` override for `load_file` / `load_data`.
2723    #[tool(
2724        description = "Dry-run schema inference on a CSV / Parquet / Arrow IPC file without ingesting. Returns the schema load_file would use (including the full-file numeric widening pass), plus per-column null_count, min, max, and sample_values. Use this BEFORE load_file if you are unsure about types or ran into a SchemaMismatch / numeric overflow — then pass an explicit `schema` override on the subsequent load_file call. Use `json_extract_path` to inspect a nested data array inside a JSON wrapper file (e.g., MCP tool responses saved to disk)."
2725    )]
2726    #[expect(
2727        clippy::unused_self,
2728        reason = "method retained on the type for API symmetry; implementation currently does not need state"
2729    )]
2730    fn inspect_file(
2731        &self,
2732        Parameters(params): Parameters<InspectFileParams>,
2733    ) -> Result<CallToolResult, rmcp::ErrorData> {
2734        if let Err(e) = crate::attach::validate_input_path(&params.path, "data file") {
2735            return Self::err_content(e);
2736        }
2737        let sample_rows = params.sample_rows.unwrap_or(5).clamp(1, 50) as usize;
2738        let result = if let Some(ref json_path) = params.json_extract_path {
2739            (|| -> Result<_, McpError> {
2740                let raw = std::fs::read_to_string(&params.path).map_err(|e| {
2741                    McpError::new(
2742                        ErrorCode::FileNotFound,
2743                        format!("Cannot read file '{}': {e}", params.path),
2744                    )
2745                })?;
2746                let file_size = std::fs::metadata(&params.path).map_or(0, |m| m.len());
2747                let extracted = crate::ingest::extract_json_path(&raw, json_path)?;
2748                crate::inspect::inspect_json_from_text(&extracted, file_size, sample_rows)
2749            })()
2750        } else {
2751            crate::inspect::inspect_source(&params.path, sample_rows)
2752        };
2753        match result {
2754            Ok(report) => Self::ok_content(report.to_json()),
2755            Err(e) => Self::err_content(e),
2756        }
2757    }
2758
2759    /// Export query results or a table to CSV, Parquet, Arrow IPC,
2760    /// Apache Iceberg, or a new `.hyper` file.
2761    #[tool(
2762        description = "Export query results or a table to a file via hyperd's native writers. Every format listed here is server-side — hyperd writes the file directly, with zero per-row work in the MCP process — and every format round-trips cleanly through the matching loader (`load_file` or `load_iceberg`).\n\nWhen choosing a format for *data leaving* Hyper, prefer in this order:\n  1. **Parquet** (recommended default): smallest output, fastest write, preserves every type (NUMERIC precision/scale, DATE, TIMESTAMP, etc.). `path` is a single file.\n  2. **Iceberg**: produces a full Apache Iceberg table directory (`metadata/` + `data/`). Use when the consumer is a data-lake tool (Spark, Trino, DuckDB, etc.). `path` is a directory that hyperd creates.\n  3. **Arrow IPC Stream** (`arrow_ipc`): same wire shape Hyper uses internally; great for handing data to another Arrow-aware process. Larger than Parquet (no compression) but extremely fast to read back. `path` is a single file.\n  4. **CSV**: portable and human-readable but the largest output and types are lost (everything becomes text). Use for spreadsheet / shell-pipeline interop. Includes header row.\n  5. **Hyper**: an entire `.hyper` database file openable directly in Tableau Desktop. `sql`/`table` are ignored — every user table is copied.\n\nAll formats except Iceberg and Hyper require either `sql` or `table`. Iceberg output is a directory; all others are single files.\n\nUse `database` to read from a non-primary source: for `format=\"hyper\"` it selects which database is snapshotted; for the row-oriented formats it routes the SELECT through the named database (when `table` is set) or pins `schema_search_path` for the call (when `sql` is set)."
2763    )]
2764    fn export(
2765        &self,
2766        Parameters(params): Parameters<ExportParams>,
2767    ) -> Result<CallToolResult, rmcp::ErrorData> {
2768        let result = self.with_engine(|engine| {
2769            // Validate output path: must be absolute, no `..` components.
2770            // (Iceberg "exports" to a directory; the same rules apply.)
2771            crate::attach::validate_output_path(&params.path, "export")?;
2772            // `format_options` must be a JSON object if supplied. Anything
2773            // else (array, string, number, null) is a caller error — reject
2774            // with a clear message rather than silently dropping it.
2775            let format_options = match params.format_options.clone() {
2776                None => None,
2777                Some(Value::Object(m)) => Some(m),
2778                Some(other) => {
2779                    return Err(McpError::new(
2780                        ErrorCode::SchemaMismatch,
2781                        format!("export: format_options must be a JSON object, got: {other}"),
2782                    ));
2783                }
2784            };
2785            // Database routing. Three strategies:
2786            // - `hyper` format + non-primary: source_db plumbed through
2787            //   into populate_export_target so the snapshot reads from
2788            //   the requested database (no need to redirect anything;
2789            //   the cross-DB CREATE TABLE AS handles it natively).
2790            // - `table` mode + non-primary: synthesize a fully-qualified
2791            //   SELECT and pass it as `sql` so export.rs's name-quoting
2792            //   doesn't double-quote our identifier.
2793            // - `sql` mode + non-primary: redirect search_path for the
2794            //   call duration so unqualified names resolve correctly.
2795            let target_db = self.resolve_db(engine, params.database.as_deref(), None, false)?;
2796            let (effective_sql, effective_table) = match (&params.sql, &params.table, &target_db) {
2797                (None, Some(t), Some(db)) => {
2798                    let esc_db = db.replace('"', "\"\"");
2799                    let esc_tbl = t.replace('"', "\"\"");
2800                    (
2801                        Some(format!(
2802                            "SELECT * FROM \"{esc_db}\".\"public\".\"{esc_tbl}\""
2803                        )),
2804                        None,
2805                    )
2806                }
2807                _ => (params.sql.clone(), params.table.clone()),
2808            };
2809            let _search_guard = match (&effective_sql, &target_db, &params.sql) {
2810                // Only pin search_path when the user supplied raw SQL
2811                // (not when we synthesized a fully-qualified SELECT).
2812                (Some(_), Some(alias), Some(_)) => Some(engine.scoped_search_path(alias)?),
2813                _ => None,
2814            };
2815            let opts = ExportOptions {
2816                sql: effective_sql,
2817                table: effective_table,
2818                path: params.path,
2819                format: params.format,
2820                overwrite: params.overwrite.unwrap_or(true),
2821                format_options,
2822                source_db: target_db.clone(),
2823            };
2824            let export_result = export_to_file(engine, &opts)?;
2825            Ok(json!({
2826                "output_path": export_result.stats.output_path,
2827                "rows": export_result.rows,
2828                "file_size_bytes": export_result.stats.file_size_bytes,
2829                "stats": export_result.stats.to_json(),
2830            }))
2831        });
2832
2833        match result {
2834            Ok(val) => Self::ok_content(val),
2835            Err(e) => Self::err_content(e),
2836        }
2837    }
2838
2839    /// Save a named read-only SQL query. After saving, the query is
2840    /// exposed as two MCP resources — see the struct-level docs on
2841    /// [`SaveQueryParams`] for the full URI pattern.
2842    #[tool(
2843        description = "Save a named read-only SQL query. Creates two resources: `hyper://queries/{name}/definition` (sql + metadata JSON) and `hyper://queries/{name}/result` (re-runs the SQL on every read). Persisted in the workspace when `--workspace` is set; session-only otherwise. Rejects non-read-only SQL and duplicate names; delete first to overwrite."
2844    )]
2845    fn save_query(
2846        &self,
2847        Parameters(params): Parameters<SaveQueryParams>,
2848    ) -> Result<CallToolResult, rmcp::ErrorData> {
2849        if let Err(e) = self.check_writable("save_query") {
2850            return Self::err_content(e);
2851        }
2852        // Enforce read-only SQL at save time. This is belt-and-braces: the
2853        // result resource runs via `execute_query_to_json` which would
2854        // reject DDL/DML anyway, but rejecting here produces a clearer
2855        // error and prevents the row landing in the meta-table at all.
2856        if !is_read_only_sql(&params.sql) {
2857            return Self::err_content(McpError::new(
2858                ErrorCode::SqlError,
2859                "save_query only accepts read-only SQL (SELECT / WITH / EXPLAIN / SHOW / VALUES). \
2860                 Use the execute tool for DDL/DML, not save_query.",
2861            ));
2862        }
2863        if params.name.is_empty() {
2864            return Self::err_content(McpError::new(
2865                ErrorCode::SchemaMismatch,
2866                "Saved query name must not be empty.",
2867            ));
2868        }
2869        let query = SavedQuery {
2870            name: params.name.clone(),
2871            sql: params.sql,
2872            description: params.description,
2873            created_at: chrono::Utc::now(),
2874        };
2875        let store = Arc::clone(&self.saved_queries);
2876        let result = self.with_saved_query_store(|engine| store.save(engine, query.clone()));
2877        match result {
2878            Ok(()) => {
2879                // Both resources for this query name are new — nudge
2880                // clients to refresh their catalog so they see the new
2881                // `hyper://queries/{name}/...` entries.
2882                self.notify_resource_list_changed();
2883                Self::ok_content(json!({
2884                    "saved": true,
2885                    "name": query.name,
2886                    "resources": [
2887                        format!("hyper://queries/{}/definition", query.name),
2888                        format!("hyper://queries/{}/result", query.name),
2889                    ],
2890                    "created_at": query.created_at.to_rfc3339(),
2891                }))
2892            }
2893            Err(e) => Self::err_content(e),
2894        }
2895    }
2896
2897    /// Delete a named saved query and its two resources.
2898    #[tool(
2899        description = "Delete a named saved query. Removes the underlying entry and both `hyper://queries/{name}/...` resources. Returns `{deleted: true}` when the query existed, `{deleted: false}` when it did not (no error)."
2900    )]
2901    fn delete_query(
2902        &self,
2903        Parameters(params): Parameters<DeleteQueryParams>,
2904    ) -> Result<CallToolResult, rmcp::ErrorData> {
2905        if let Err(e) = self.check_writable("delete_query") {
2906            return Self::err_content(e);
2907        }
2908        let store = Arc::clone(&self.saved_queries);
2909        let name = params.name.clone();
2910        let result = self.with_saved_query_store(|engine| store.delete(engine, &name));
2911        match result {
2912            Ok(deleted) => {
2913                if deleted {
2914                    // Two resources just disappeared — fan out a
2915                    // list_changed and targeted updates so any subscriber
2916                    // holding stale `hyper://queries/{name}/...` state
2917                    // drops it.
2918                    self.notify_resource_list_changed();
2919                    self.subscriptions
2920                        .notify_updated(&format!("hyper://queries/{name}/definition"));
2921                    self.subscriptions
2922                        .notify_updated(&format!("hyper://queries/{name}/result"));
2923                }
2924                Self::ok_content(json!({
2925                    "deleted": deleted,
2926                    "name": params.name,
2927                }))
2928            }
2929            Err(e) => Self::err_content(e),
2930        }
2931    }
2932
2933    /// Update prose metadata for a table in the `_table_catalog`.
2934    #[tool(
2935        description = "Update prose metadata for a table in the `_table_catalog`: source_url, source_description, purpose, license, notes, data_url. Fields you omit stay unchanged; pass an explicit empty string (\"\") to clear a field. `data_url` is the machine-actionable download URL for the raw data file (distinct from `source_url`, which is a human-readable reference page). Mechanical fields (load_tool, load_params, loaded_at, last_refreshed_at, row_count) are managed by the server. Requires an existing catalog entry — load the table first (load_file / load_data / execute CREATE TABLE) so the stub row is created automatically. Use `database` to target the metadata for a table in a non-primary writable database; read-only attachments are rejected with a clear re-attach-with-writable message. Disabled in read-only mode."
2936    )]
2937    fn set_table_metadata(
2938        &self,
2939        Parameters(params): Parameters<SetTableMetadataParams>,
2940    ) -> Result<CallToolResult, rmcp::ErrorData> {
2941        if let Err(e) = self.check_writable("set_table_metadata") {
2942            return Self::err_content(e);
2943        }
2944        let fields = crate::table_catalog::MetadataFields {
2945            source_url: params.source_url,
2946            source_description: params.source_description,
2947            purpose: params.purpose,
2948            license: params.license,
2949            notes: params.notes,
2950            data_url: params.data_url,
2951        };
2952        let table_name = params.table.clone();
2953        let result = self.with_engine(|engine| {
2954            // Resolve target with require_writable=true so read-only
2955            // attachments are rejected BEFORE any catalog write
2956            // (defense-in-depth: ensure_exists_in's CREATE TABLE
2957            // would also fail at the Hyper layer, but the resolve_db
2958            // error is more actionable).
2959            let target_db = self.resolve_db(engine, params.database.as_deref(), None, true)?;
2960            crate::table_catalog::set_metadata_in(
2961                engine,
2962                &table_name,
2963                &fields,
2964                target_db.as_deref(),
2965            )
2966        });
2967        match result {
2968            Ok(entry) => Self::ok_content(entry.to_json()),
2969            Err(e) => Self::err_content(e),
2970        }
2971    }
2972
2973    /// Returns plugin health, workspace info, table count, total rows, disk
2974    /// usage, the backing `hyperd` connection (mode, endpoint, daemon health
2975    /// port), and the list of active directory watchers with their stats.
2976    #[tool(
2977        description = "Returns plugin health, workspace info, table count, total rows, disk usage, the backing hyperd connection (engine.mode, engine.hyperd_endpoint, engine.daemon_health_port), and active directory watchers."
2978    )]
2979    fn status(&self) -> Result<CallToolResult, rmcp::ErrorData> {
2980        // Use try_lock so `status` never hangs behind a stalled/slow data-plane
2981        // operation on the same session (issue #118). If the engine lock is held
2982        // by another tool call, return a degraded-but-instant response with the
2983        // metadata available without the engine (daemon health, paths, watchers).
2984        //
2985        // `status` is a pure observer: it never initializes the engine. The
2986        // engine is initialized eagerly at server startup (see
2987        // `warm_up_engine`) and lazily by data-plane tools via `with_engine`,
2988        // so by the time a client can call any tool the engine is normally
2989        // already `Some`. If it is still `None` here (eager init failed because
2990        // hyperd was down at startup, or a ConnectionLost just dropped it), we
2991        // report the degraded response honestly rather than blocking to init.
2992        let Ok(guard) = self.engine.try_lock() else {
2993            return Self::ok_content(self.status_degraded());
2994        };
2995        let Some(engine) = guard.as_ref() else {
2996            return Self::ok_content(self.status_degraded());
2997        };
2998        self.ensure_catalog_ready(engine);
2999        let result = engine.status();
3000
3001        match result {
3002            Ok(mut val) => {
3003                if let Some(obj) = val.as_object_mut() {
3004                    obj.insert("engine_busy".into(), json!(false));
3005                    obj.insert("watchers".into(), self.watchers.to_json());
3006                    obj.insert("read_only".into(), json!(self.read_only));
3007                    let attachments: Vec<Value> = self
3008                        .attachments
3009                        .list()
3010                        .iter()
3011                        .map(super::attach::AttachedDb::to_json)
3012                        .collect();
3013                    obj.insert("attachments".into(), Value::Array(attachments));
3014                }
3015                Self::ok_content(val)
3016            }
3017            Err(e) => Self::err_content(e),
3018        }
3019    }
3020
3021    /// Returns a concise LLM-facing README. Stateless — works
3022    /// identically in read-only mode. The text itself documents
3023    /// read-only restrictions, so the tool doesn't branch on
3024    /// `self.read_only`.
3025    #[tool(
3026        description = "Returns a concise LLM-facing README explaining what this MCP does, which tool to use for what, key parameter rules, SQL dialect quirks, and usage examples. Call this once at the start of a session to ground the model in the surface area before issuing other tool calls."
3027    )]
3028    #[expect(
3029        clippy::unused_self,
3030        reason = "the #[tool] macro dispatches on &self; signature must match the rest of the tool surface even though this tool is stateless"
3031    )]
3032    #[expect(
3033        clippy::unnecessary_wraps,
3034        reason = "uniform Result<CallToolResult, rmcp::ErrorData> across all tools so the #[tool_router] dispatcher has one signature shape"
3035    )]
3036    fn get_readme(&self) -> Result<CallToolResult, rmcp::ErrorData> {
3037        Ok(CallToolResult::success(vec![Content::text(
3038            crate::readme::README,
3039        )]))
3040    }
3041
3042    /// Attach an additional `.hyper` database under a user-chosen
3043    /// alias so its tables can participate in cross-database queries.
3044    #[tool(
3045        description = "Attach an additional .hyper database under a chosen alias. Tables in the attachment are addressable as `{alias}.public.{table}` in any subsequent SELECT; tables in the primary workspace remain addressable as `local.public.{table}` or by their file stem. Default is read-only; pass writable:true to allow mutations (still respects --read-only). Set on_missing='create' (with writable:true) to create an empty .hyper file at the target path first and then attach it — useful for scratch databases without a separate file-creation step; the parent directory must already exist. Only kind='local_file' is supported today; 'tcp' and 'grpc' (Data 360) are planned. The alias 'local' is reserved for the primary workspace."
3046    )]
3047    fn attach_database(
3048        &self,
3049        Parameters(params): Parameters<AttachDatabaseParams>,
3050    ) -> Result<CallToolResult, rmcp::ErrorData> {
3051        let writable = params.writable.unwrap_or(false);
3052        if writable {
3053            if let Err(e) = self.check_writable("attach_database(writable)") {
3054                return Self::err_content(e);
3055            }
3056        }
3057        let on_missing = match attach::OnMissing::parse(params.on_missing.as_deref()) {
3058            Ok(v) => v,
3059            Err(e) => return Self::err_content(e),
3060        };
3061        if on_missing == attach::OnMissing::Create && !writable {
3062            return Self::err_content(McpError::new(
3063                ErrorCode::InvalidArgument,
3064                "on_missing='create' requires writable:true — an empty .hyper file that cannot be written to cannot be populated.",
3065            ));
3066        }
3067        let source = match params.kind.as_str() {
3068            "local_file" => {
3069                let Some(raw) = params.path.as_deref() else {
3070                    return Self::err_content(McpError::new(
3071                        ErrorCode::InvalidArgument,
3072                        "kind='local_file' requires a 'path' argument",
3073                    ));
3074                };
3075                let resolved = match on_missing {
3076                    attach::OnMissing::Error => attach::validate_local_path(raw),
3077                    attach::OnMissing::Create => attach::validate_local_path_for_create(raw),
3078                };
3079                match resolved {
3080                    Ok(canonical) => AttachSource::LocalFile { path: canonical },
3081                    Err(e) => return Self::err_content(e),
3082                }
3083            }
3084            other => {
3085                return Self::err_content(McpError::new(
3086                    ErrorCode::InvalidArgument,
3087                    format!(
3088                        "Unsupported attach kind '{other}'. Only 'local_file' is supported today; \
3089                         'tcp' (remote hyperd) and 'grpc' (Data 360) are planned."
3090                    ),
3091                ));
3092            }
3093        };
3094        let req = AttachRequest {
3095            alias: params.alias.clone(),
3096            source,
3097            writable,
3098            on_missing,
3099        };
3100        let registry = self.attachments_handle();
3101        let alias_for_probe = req.alias.clone();
3102        let result = self.with_engine(|engine| {
3103            let entry = registry.attach(engine, req.clone())?;
3104            // Best-effort probe for a table count against the new
3105            // alias so the LLM sees what just came online without a
3106            // separate round-trip. Failures here don't invalidate the
3107            // attach — log and return `null` instead.
3108            let tables_visible = probe_table_count(engine, &alias_for_probe);
3109            Ok(json!({
3110                "alias": entry.alias,
3111                "kind": entry.source.kind_str(),
3112                "source": entry.source.to_json(),
3113                "writable": entry.writable,
3114                "tables_visible": tables_visible,
3115            }))
3116        });
3117        match result {
3118            Ok(val) => Self::ok_content(val),
3119            Err(e) => Self::err_content(e),
3120        }
3121    }
3122
3123    /// Detach a previously attached database.
3124    #[tool(
3125        description = "Detach a database previously registered with attach_database. No-op when the alias is unknown. Returns {detached: true/false}."
3126    )]
3127    fn detach_database(
3128        &self,
3129        Parameters(params): Parameters<DetachDatabaseParams>,
3130    ) -> Result<CallToolResult, rmcp::ErrorData> {
3131        // Canonicalize to the registry's stored form. Aliases are
3132        // lowercased at attach time; watcher `target_db` is also stored
3133        // canonicalized (via `Engine::resolve_target_db`), so an exact
3134        // `==` comparison suffices below.
3135        let alias = params.alias.to_ascii_lowercase();
3136        // Reject if any active watcher targets this alias. Otherwise the
3137        // watcher's pool would keep ingesting into the now-detached
3138        // workspace path; or, if the user re-attached the same alias to
3139        // a different file, into the wrong database. Fixed by stopping
3140        // the watcher first via `unwatch_directory`.
3141        if let Ok(watchers) = self.watchers.watchers.lock() {
3142            let conflict = watchers
3143                .values()
3144                .find(|h| h.target_db.as_deref() == Some(alias.as_str()));
3145            if let Some(h) = conflict {
3146                return Self::err_content(McpError::new(
3147                    ErrorCode::InvalidArgument,
3148                    format!(
3149                        "cannot detach '{alias}': an active watcher on directory '{}' targets it. \
3150                         Call unwatch_directory(\"{}\") first.",
3151                        h.directory.display(),
3152                        h.directory.display()
3153                    ),
3154                ));
3155            }
3156        }
3157        let registry = self.attachments_handle();
3158        let result = self.with_engine(|engine| {
3159            let outcome = registry.detach(engine, &alias)?;
3160            if outcome {
3161                // Drop any cached "_table_catalog exists in this alias"
3162                // probe so a re-attach to a different file or with
3163                // different writability won't reuse a stale entry.
3164                engine.clear_catalog_cache_for(&alias);
3165            }
3166            Ok(outcome)
3167        });
3168        match result {
3169            Ok(detached) => {
3170                Self::ok_content(json!({ "alias": params.alias, "detached": detached }))
3171            }
3172            Err(e) => Self::err_content(e),
3173        }
3174    }
3175
3176    /// List currently attached databases.
3177    ///
3178    /// Named `list_attached_databases` (not `list_attached`) so it
3179    /// sits alongside `attach_database` / `detach_database` as a
3180    /// symmetric verb-database trio. The earlier `list_attached`
3181    /// name broke the pattern and consistently misled LLM callers
3182    /// into hallucinating `list_attached_databases` anyway, so the
3183    /// tool now matches the name the models were already reaching
3184    /// for.
3185    #[tool(
3186        description = "List every database currently attached under an alias: kind, path/endpoint, writable flag, attach time, and (best-effort) a count of visible public-schema tables."
3187    )]
3188    fn list_attached_databases(&self) -> Result<CallToolResult, rmcp::ErrorData> {
3189        let result = self.with_engine(|engine| {
3190            let entries = self.attachments.list();
3191            let attachments: Vec<Value> = entries
3192                .iter()
3193                .map(|entry| {
3194                    let mut obj = entry.to_json();
3195                    let tables_visible = probe_table_count(engine, &entry.alias);
3196                    if let Some(map) = obj.as_object_mut() {
3197                        map.insert("tables_visible".into(), json!(tables_visible));
3198                    }
3199                    obj
3200                })
3201                .collect();
3202            Ok(json!({ "attachments": attachments }))
3203        });
3204        match result {
3205            Ok(val) => Self::ok_content(val),
3206            Err(e) => Self::err_content(e),
3207        }
3208    }
3209
3210    /// Run a SELECT across local + attached databases and land the
3211    /// result into a target table. All three modes (`create`,
3212    /// `append`, `replace`) are explicit — the target's actual
3213    /// existence must match the chosen mode.
3214    #[tool(
3215        description = "Run a SELECT (or WITH / VALUES) across local and attached databases and insert the result into a target table. Required `mode`: 'create' (target must not exist, creates via CREATE TABLE AS), 'append' (target must exist, INSERT INTO ... SELECT), or 'replace' (drops and recreates atomically). `target_database` defaults to the primary workspace ('local' also accepted); any other value must be an attachment registered with writable:true. Optional `temp_attach` attaches additional databases for this call only and detaches them on exit (even on failure). Disabled in read-only mode."
3216    )]
3217    fn copy_query(
3218        &self,
3219        Parameters(params): Parameters<CopyQueryParams>,
3220    ) -> Result<CallToolResult, rmcp::ErrorData> {
3221        if let Err(e) = self.check_writable("copy_query") {
3222            return Self::err_content(e);
3223        }
3224        let mode = match params.mode.as_str() {
3225            "create" | "append" | "replace" => params.mode.clone(),
3226            other => {
3227                return Self::err_content(McpError::new(
3228                    ErrorCode::InvalidArgument,
3229                    format!(
3230                        "copy_query mode '{other}' is not supported. Use 'create', 'append', or 'replace'."
3231                    ),
3232                ));
3233            }
3234        };
3235        if !is_read_only_sql(&params.sql) {
3236            return Self::err_content(McpError::new(
3237                ErrorCode::SqlError,
3238                "copy_query's `sql` must be a read-only statement (SELECT / WITH / VALUES). \
3239                 Use the execute tool for raw DDL/DML.",
3240            ));
3241        }
3242        // `target_database = None` and `"local"` both map to the
3243        // primary workspace (unqualified target name). Anything else
3244        // must refer to an attached writable database.
3245        //
3246        // Canonicalize to the registry's lowercase storage form before
3247        // both the registry lookup AND the qualified-SQL build path
3248        // (`perform_copy` → `qualified_name`). Hyper is case-sensitive
3249        // on quoted identifiers; without canonicalization here, a user
3250        // attaching as `"My_DB"` (which the registry stores as
3251        // `"my_db"`) and calling `copy_query(target_database="My_DB")`
3252        // would fail with "database does not exist" once SQL renders.
3253        let target_db_owned = params
3254            .target_database
3255            .as_deref()
3256            .filter(|s| !s.eq_ignore_ascii_case(LOCAL_ALIAS))
3257            .map(str::to_ascii_lowercase);
3258        let target_db = target_db_owned.as_deref();
3259        if let Some(alias) = target_db {
3260            match self.attachments.get(alias) {
3261                None => {
3262                    return Self::err_content(McpError::new(
3263                        ErrorCode::InvalidArgument,
3264                        format!(
3265                            "target_database '{alias}' is not attached. Call attach_database first."
3266                        ),
3267                    ));
3268                }
3269                Some(entry) if !entry.writable => {
3270                    return Self::err_content(McpError::new(
3271                        ErrorCode::InvalidArgument,
3272                        format!(
3273                            "target_database '{alias}' was attached read-only. Re-attach with writable:true to use it as a copy target."
3274                        ),
3275                    ));
3276                }
3277                Some(_) => {}
3278            }
3279        }
3280
3281        // Pre-validate any temp_attach requests *before* we touch the
3282        // engine so a bad spec aborts cleanly without a partial attach.
3283        let temp_specs = params.temp_attach.clone().unwrap_or_default();
3284        let prepared_temps = match prepare_temp_attachments(&temp_specs, self.is_read_only()) {
3285            Ok(v) => v,
3286            Err(e) => return Self::err_content(e),
3287        };
3288
3289        let target_table = params.target_table.clone();
3290        let sql_body = params.sql.clone();
3291        let load_params = serde_json::to_string(&json!({
3292            "mode": mode,
3293            "target_database": params.target_database,
3294            "target_table": target_table,
3295            "sql": Self::fmt_sql(&sql_body),
3296        }))
3297        .ok();
3298
3299        let registry = self.attachments_handle();
3300        let result = self.with_engine(|engine| {
3301            // Phase 1: install temp attachments.
3302            let mut temp_aliases: Vec<String> = Vec::new();
3303            for req in &prepared_temps {
3304                match registry.attach(engine, req.clone()) {
3305                    Ok(entry) => temp_aliases.push(entry.alias),
3306                    Err(e) => {
3307                        // Roll back attachments installed so far.
3308                        for alias in &temp_aliases {
3309                            let _ = registry.detach(engine, alias);
3310                        }
3311                        return Err(e);
3312                    }
3313                }
3314            }
3315
3316            // Phase 2: run the actual copy inside a helper so the
3317            // cleanup path is unified.
3318            let copy_outcome = perform_copy(engine, &mode, target_db, &target_table, &sql_body);
3319
3320            // Phase 3: always detach the temp attachments, even on
3321            // error — they were installed only for the duration of
3322            // this call.
3323            for alias in &temp_aliases {
3324                if let Err(e) = registry.detach(engine, alias) {
3325                    tracing::warn!(
3326                        alias = %alias,
3327                        err = %e.message,
3328                        "failed to detach temp attachment after copy_query",
3329                    );
3330                }
3331            }
3332
3333            // Phase 4: stamp `_table_catalog` inside the same engine
3334            // borrow the copy just ran under. Kept next to the copy
3335            // (rather than spun off in a second `with_engine`) so the
3336            // stub and the data it describes can't diverge — a new
3337            // engine might not even have the catalog materialized yet.
3338            // Skipped when the destination is an attached database
3339            // (their catalog isn't ours) or when the server is bare /
3340            // read-only. `after_ingest_catalog_update` logs WARN on
3341            // failure, matching how `load_file` / `load_data` /
3342            // `execute` register their provenance.
3343            if copy_outcome.is_ok() && target_db.is_none() {
3344                let row_count = copy_outcome
3345                    .as_ref()
3346                    .ok()
3347                    .and_then(|v| v.get("row_count").and_then(serde_json::Value::as_i64));
3348                self.after_ingest_catalog_update(
3349                    engine,
3350                    &target_table,
3351                    "copy_query",
3352                    load_params.as_deref(),
3353                    row_count,
3354                    target_db,
3355                );
3356            }
3357
3358            copy_outcome
3359        });
3360
3361        match result {
3362            Ok(outcome) => {
3363                // Fan out resource updates so subscribers refresh.
3364                if target_db.is_none() {
3365                    self.notify_table_changed(&target_table);
3366                }
3367                self.notify_workspace_changed();
3368                if mode != "append" {
3369                    // `create` / `replace` add or recreate the table,
3370                    // which is a resource-list-changing event.
3371                    self.notify_resource_list_changed();
3372                }
3373                Self::ok_content(outcome)
3374            }
3375            Err(e) => Self::err_content(e),
3376        }
3377    }
3378}
3379
3380// --- Prompts ---
3381
3382#[prompt_router]
3383impl HyperMcpServer {
3384    /// Deep analysis of a single table: schema, sample, column statistics, data quality flags.
3385    #[prompt(
3386        name = "analyze-table",
3387        description = "Deep analysis of a single table: schema, sample, column stats, data quality"
3388    )]
3389    pub async fn analyze_table(
3390        &self,
3391        Parameters(args): Parameters<AnalyzeTableArgs>,
3392    ) -> Vec<PromptMessage> {
3393        let context = self.build_analyze_context(&args.table);
3394        vec![
3395            PromptMessage::new_text(
3396                PromptMessageRole::User,
3397                format!(
3398                    "Analyze the `{}` table thoroughly.\n\n{}\n\nPlease:\n\
3399                    1. Describe each column (what it likely represents based on name and sample values)\n\
3400                    2. Compute basic statistics using the query tool: min/max/avg for numeric columns, distinct count and top values for text columns\n\
3401                    3. Flag any data quality issues: unexpected NULLs, suspicious outliers, inconsistent formats\n\
3402                    4. Summarize your findings in plain English",
3403                    args.table, context
3404                ),
3405            ),
3406            PromptMessage::new_text(
3407                PromptMessageRole::Assistant,
3408                format!(
3409                    "I'll analyze the `{}` table systematically. Let me start by examining the schema and sample, then run targeted queries for statistics and data quality.",
3410                    args.table
3411                ),
3412            ),
3413        ]
3414    }
3415
3416    /// Compare two tables side-by-side: schema alignment, common keys, JOIN suggestions.
3417    #[prompt(
3418        name = "compare-tables",
3419        description = "Compare two tables: schema alignment, common keys, JOIN opportunities"
3420    )]
3421    pub async fn compare_tables(
3422        &self,
3423        Parameters(args): Parameters<CompareTablesArgs>,
3424    ) -> Vec<PromptMessage> {
3425        let ctx_a = self.build_brief_context(&args.table_a);
3426        let ctx_b = self.build_brief_context(&args.table_b);
3427        vec![
3428            PromptMessage::new_text(
3429                PromptMessageRole::User,
3430                format!(
3431                    "Compare these two tables:\n\n## Table A: `{}`\n{}\n\n## Table B: `{}`\n{}\n\nPlease:\n\
3432                    1. Identify columns that appear in both tables (by name or semantic match)\n\
3433                    2. Suggest likely JOIN keys and the JOIN type (inner, left, etc.)\n\
3434                    3. Highlight schema differences (column types, nullability)\n\
3435                    4. Propose 3-5 analytical queries that combine both tables and explain what each reveals",
3436                    args.table_a, ctx_a, args.table_b, ctx_b
3437                ),
3438            ),
3439            PromptMessage::new_text(
3440                PromptMessageRole::Assistant,
3441                format!(
3442                    "I'll compare `{}` and `{}` systematically — schema alignment first, then join keys, then analytical opportunities.",
3443                    args.table_a, args.table_b
3444                ),
3445            ),
3446        ]
3447    }
3448
3449    /// Systematic data quality assessment: nulls, duplicates, cardinality, outliers.
3450    #[prompt(
3451        name = "data-quality",
3452        description = "Systematic data quality assessment: NULL rates, duplicates, low cardinality, outliers"
3453    )]
3454    pub async fn data_quality(
3455        &self,
3456        Parameters(args): Parameters<DataQualityArgs>,
3457    ) -> Vec<PromptMessage> {
3458        let context = self.build_brief_context(&args.table);
3459        vec![
3460            PromptMessage::new_text(
3461                PromptMessageRole::User,
3462                format!(
3463                    "Run a data quality assessment on the `{}` table.\n\n{}\n\nPlease use the query tool to check:\n\
3464                    1. NULL rate per column — run SELECT COUNT(*) FILTER (WHERE col IS NULL) / COUNT(*) for each column\n\
3465                    2. Duplicate rows — compare COUNT(*) vs COUNT(DISTINCT *) or use GROUP BY\n\
3466                    3. Low-cardinality columns — columns with suspiciously few distinct values\n\
3467                    4. Numeric outliers — values more than 3 stddev from the mean\n\
3468                    5. Date sanity — future dates or impossibly old dates in date/timestamp columns\n\n\
3469                    Summarize findings with severity (critical / warning / info) and suggest remediation for each issue.",
3470                    args.table, context
3471                ),
3472            ),
3473            PromptMessage::new_text(
3474                PromptMessageRole::Assistant,
3475                format!(
3476                    "I'll perform a systematic data quality assessment on `{}`. Let me run targeted queries for each check category.",
3477                    args.table
3478                ),
3479            ),
3480        ]
3481    }
3482
3483    /// Propose useful analytical queries for a table, optionally guided by a goal.
3484    #[prompt(
3485        name = "suggest-queries",
3486        description = "Suggest analytical SQL queries for a table, optionally guided by a goal"
3487    )]
3488    pub async fn suggest_queries(
3489        &self,
3490        Parameters(args): Parameters<SuggestQueriesArgs>,
3491    ) -> Vec<PromptMessage> {
3492        let context = self.build_analyze_context(&args.table);
3493        let goal_section = match args.goal.as_deref() {
3494            Some(g) if !g.is_empty() => format!("\n\nSpecific goal: {g}"),
3495            _ => String::new(),
3496        };
3497        vec![
3498            PromptMessage::new_text(
3499                PromptMessageRole::User,
3500                format!(
3501                    "Given the `{}` table:\n\n{}{}\n\nSuggest 5 analytical SQL queries that would be useful for exploring this data. \
3502                    For each query, provide:\n\
3503                    - A descriptive title\n\
3504                    - The exact SQL (valid for Hyper / PostgreSQL-compatible syntax)\n\
3505                    - One sentence explaining what insight it reveals\n\n\
3506                    Prefer queries that use aggregations, GROUP BY, window functions, or CTEs to demonstrate the power of SQL analytics.",
3507                    args.table, context, goal_section
3508                ),
3509            ),
3510            PromptMessage::new_text(
3511                PromptMessageRole::Assistant,
3512                format!(
3513                    "Based on the schema and sample of `{}`, here are 5 analytical queries.",
3514                    args.table
3515                ),
3516            ),
3517        ]
3518    }
3519}
3520
3521/// The payload of a resource read, carrying both MIME type and serialized
3522/// content. Different resources speak different formats (JSON for metadata,
3523/// markdown for human overviews, CSV for spreadsheet consumers), so the
3524/// resource layer needs to pass both along to the MCP client.
3525///
3526/// `Json` variants are pretty-printed when rendered; `Text` variants are
3527/// emitted verbatim. Tests and prompt helpers can still access the
3528/// underlying JSON via [`ResourceBody::as_json`] when it's a JSON payload.
3529#[derive(Debug, Clone)]
3530pub enum ResourceBody {
3531    /// Structured JSON — rendered as pretty-printed `application/json`.
3532    Json(Value),
3533    /// Free-form text with an explicit MIME type (e.g. `text/markdown`,
3534    /// `text/csv`).
3535    Text {
3536        /// IANA media type, e.g. `text/markdown` or `text/csv`.
3537        mime_type: String,
3538        /// The literal text to return to the client, verbatim.
3539        content: String,
3540    },
3541}
3542
3543impl ResourceBody {
3544    /// Return the MIME type this body will be served with.
3545    #[must_use]
3546    pub fn mime_type(&self) -> &str {
3547        match self {
3548            ResourceBody::Json(_) => "application/json",
3549            ResourceBody::Text { mime_type, .. } => mime_type,
3550        }
3551    }
3552
3553    /// Render the body to the text payload the client will receive.
3554    /// JSON variants are pretty-printed; text variants return as-is.
3555    #[must_use]
3556    pub fn to_text(&self) -> String {
3557        match self {
3558            ResourceBody::Json(v) => {
3559                serde_json::to_string_pretty(v).unwrap_or_else(|_| v.to_string())
3560            }
3561            ResourceBody::Text { content, .. } => content.clone(),
3562        }
3563    }
3564
3565    /// Borrow the underlying `Value` when this body is JSON. Useful for
3566    /// tests that want to assert on individual fields without reparsing.
3567    #[must_use]
3568    pub fn as_json(&self) -> Option<&Value> {
3569        match self {
3570            ResourceBody::Json(v) => Some(v),
3571            ResourceBody::Text { .. } => None,
3572        }
3573    }
3574}
3575
3576impl HyperMcpServer {
3577    /// Produce the body for a resource URI without constructing an MCP
3578    /// `RequestContext`. Factored out of [`Self::read_resource`] so tests can
3579    /// exercise URI dispatch without standing up the full MCP runtime.
3580    ///
3581    /// Returns `Ok(None)` if the URI isn't recognized at all (the async trait
3582    /// method surfaces this as an `invalid_params` error to clients).
3583    ///
3584    /// The returned [`ResourceBody`] carries its own MIME type so non-JSON
3585    /// resources (`hyper://readme`, `hyper://tables/{name}/csv-sample`,
3586    /// etc.) can be served verbatim as markdown / CSV.
3587    ///
3588    /// # Errors
3589    ///
3590    /// Propagates any [`McpError`] from the underlying engine call
3591    /// (status probe, table description, CSV sample, saved-query listing,
3592    /// etc.) and bubbles up [`ErrorCode::TableNotFound`] for
3593    /// `hyper://tables/{name}/...` URIs whose table is absent from the
3594    /// workspace.
3595    pub fn resource_body_for_uri(&self, uri: &str) -> Result<Option<ResourceBody>, McpError> {
3596        if uri == "hyper://workspace" {
3597            return self
3598                .with_engine(super::engine::Engine::status)
3599                .map(|v| Some(ResourceBody::Json(v)));
3600        }
3601        if uri == "hyper://tables" {
3602            return self
3603                .with_engine(|engine| {
3604                    engine
3605                        .describe_tables()
3606                        .map(|tables| json!({ "tables": tables }))
3607                })
3608                .map(|v| Some(ResourceBody::Json(v)));
3609        }
3610        if uri == "hyper://readme" {
3611            return self.build_readme_body().map(Some);
3612        }
3613        if let Some(name) = uri
3614            .strip_prefix("hyper://tables/")
3615            .and_then(|rest| rest.strip_suffix("/schema"))
3616        {
3617            let name = name.to_string();
3618            return self
3619                .with_engine(|engine| {
3620                    let tables = engine.describe_tables()?;
3621                    tables
3622                        .into_iter()
3623                        .find(|t| t.get("name").and_then(|v| v.as_str()) == Some(name.as_str()))
3624                        .ok_or_else(|| {
3625                            McpError::new(
3626                                ErrorCode::TableNotFound,
3627                                format!("Table '{name}' does not exist"),
3628                            )
3629                        })
3630                })
3631                .map(|v| Some(ResourceBody::Json(v)));
3632        }
3633        if let Some(name) = uri
3634            .strip_prefix("hyper://tables/")
3635            .and_then(|rest| rest.strip_suffix("/sample"))
3636        {
3637            let name = name.to_string();
3638            return self
3639                .with_engine(|engine| engine.sample_table(&name, TABLE_SAMPLE_ROWS))
3640                .map(|v| Some(ResourceBody::Json(v)));
3641        }
3642        if let Some(name) = uri
3643            .strip_prefix("hyper://tables/")
3644            .and_then(|rest| rest.strip_suffix("/csv-sample"))
3645        {
3646            let name = name.to_string();
3647            return self.build_csv_sample_body(&name).map(Some);
3648        }
3649        if let Some(name) = uri
3650            .strip_prefix("hyper://queries/")
3651            .and_then(|rest| rest.strip_suffix("/definition"))
3652        {
3653            return self.build_saved_query_definition(name).map(Some);
3654        }
3655        if let Some(name) = uri
3656            .strip_prefix("hyper://queries/")
3657            .and_then(|rest| rest.strip_suffix("/result"))
3658        {
3659            return self.build_saved_query_result(name).map(Some);
3660        }
3661        Ok(None)
3662    }
3663
3664    /// Build `hyper://queries/{name}/definition`: the stored SQL plus
3665    /// metadata, as JSON. Returns a `TableNotFound` error when no saved
3666    /// query has that name.
3667    fn build_saved_query_definition(&self, name: &str) -> Result<ResourceBody, McpError> {
3668        let store = Arc::clone(&self.saved_queries);
3669        let name = name.to_string();
3670        let query = self.with_saved_query_store(|engine| store.get(engine, &name))?;
3671        match query {
3672            Some(q) => Ok(ResourceBody::Json(q.to_json())),
3673            None => Err(McpError::new(
3674                ErrorCode::TableNotFound,
3675                format!("No saved query named '{name}'"),
3676            )),
3677        }
3678    }
3679
3680    /// Build `hyper://queries/{name}/result`: re-run the stored SQL on
3681    /// every read and return `{ result: [...], stats: {...} }`. Fresh by
3682    /// default — there is no cache, and the underlying engine is fast
3683    /// enough that caching isn't worth the staleness risk.
3684    fn build_saved_query_result(&self, name: &str) -> Result<ResourceBody, McpError> {
3685        let store = Arc::clone(&self.saved_queries);
3686        let name_owned = name.to_string();
3687        let query = self
3688            .with_saved_query_store(|engine| store.get(engine, &name_owned))?
3689            .ok_or_else(|| {
3690                McpError::new(
3691                    ErrorCode::TableNotFound,
3692                    format!("No saved query named '{name_owned}'"),
3693                )
3694            })?;
3695        let sql = query.sql.clone();
3696        let body = self.with_engine(|engine| {
3697            let timer = crate::stats::StatsTimer::start();
3698            let rows = engine.execute_query_to_json(&sql)?;
3699            let elapsed = timer.elapsed_ms();
3700            let result_size = serde_json::to_string(&rows).map_or(0, |s| s.len() as u64);
3701            let stats = crate::stats::QueryStats {
3702                operation: "saved_query".into(),
3703                rows_returned: rows.len() as u64,
3704                rows_scanned: 0,
3705                elapsed_ms: elapsed,
3706                result_size_bytes: result_size,
3707                tables_touched: vec![],
3708            };
3709            Ok(json!({
3710                "name": query.name,
3711                "sql": Self::fmt_sql(&query.sql),
3712                "result": rows,
3713                "stats": stats.to_json(),
3714            }))
3715        })?;
3716        Ok(ResourceBody::Json(body))
3717    }
3718
3719    /// Produce the list of MCP resources without constructing an MCP
3720    /// `RequestContext`. Factored out of [`Self::list_resources`] for tests.
3721    ///
3722    /// Returns one URI for the workspace, one for the full tables list, one
3723    /// for the workspace readme, three per existing table (schema, sample,
3724    /// csv-sample), and two per saved query (definition, result).
3725    #[must_use]
3726    pub fn list_resource_uris(&self) -> Vec<String> {
3727        let mut uris = vec![
3728            "hyper://workspace".to_string(),
3729            "hyper://tables".to_string(),
3730            "hyper://readme".to_string(),
3731        ];
3732        if let Ok(tables) = self.with_engine(super::engine::Engine::describe_tables) {
3733            // `describe_tables` already filters out `_hyperdb_*` meta-
3734            // tables via `is_internal_table`, so any table we see here
3735            // is user-visible.
3736            for table in tables {
3737                if let Some(name) = table.get("name").and_then(|v| v.as_str()) {
3738                    uris.push(format!("hyper://tables/{name}/schema"));
3739                    uris.push(format!("hyper://tables/{name}/sample"));
3740                    uris.push(format!("hyper://tables/{name}/csv-sample"));
3741                }
3742            }
3743        }
3744        let store = Arc::clone(&self.saved_queries);
3745        if let Ok(saved) = self.with_saved_query_store(|engine| store.list(engine)) {
3746            for q in saved {
3747                uris.push(format!("hyper://queries/{}/definition", q.name));
3748                uris.push(format!("hyper://queries/{}/result", q.name));
3749            }
3750        }
3751        uris
3752    }
3753
3754    /// Build the `hyper://readme` markdown body: a human-friendly overview
3755    /// of the current workspace, its tables, and pointers to the other
3756    /// resources and tools an LLM might reach for.
3757    ///
3758    /// Designed to be dropped into an LLM context block so the model can
3759    /// orient itself in a single resource read without first calling
3760    /// `status` and `describe` tools.
3761    fn build_readme_body(&self) -> Result<ResourceBody, McpError> {
3762        let status = self.with_engine(super::engine::Engine::status)?;
3763        let tables = self
3764            .with_engine(super::engine::Engine::describe_tables)
3765            .unwrap_or_default();
3766
3767        let workspace_mode = status
3768            .get("workspace_mode")
3769            .and_then(|v| v.as_str())
3770            .unwrap_or("unknown");
3771        let workspace_path = status
3772            .get("workspace_path")
3773            .and_then(|v| v.as_str())
3774            .unwrap_or("");
3775        let read_only = status
3776            .get("read_only")
3777            .and_then(serde_json::Value::as_bool)
3778            .unwrap_or(false);
3779        let table_count = tables.len();
3780
3781        let mut md = String::new();
3782        md.push_str("# HyperDB workspace\n\n");
3783        let _ = writeln!(
3784            md,
3785            "- Mode: **{workspace_mode}**{}\n",
3786            if read_only { " (read-only)" } else { "" }
3787        );
3788        if !workspace_path.is_empty() {
3789            let _ = writeln!(md, "- Path: `{workspace_path}`\n");
3790        }
3791        let _ = write!(md, "- Tables: **{table_count}**\n\n");
3792
3793        if tables.is_empty() {
3794            md.push_str(
3795                "_No tables loaded yet._ Use the `load_file` or `load_data` tools to \
3796                 ingest CSV / JSON / Parquet / Arrow IPC data; call `inspect_file` \
3797                 first if you're unsure of the schema.\n",
3798            );
3799        } else {
3800            md.push_str("## Tables\n\n");
3801            md.push_str("| Table | Rows | Columns |\n");
3802            md.push_str("|---|---:|---|\n");
3803            for t in &tables {
3804                let name = t.get("name").and_then(|v| v.as_str()).unwrap_or("?");
3805                let rows = t
3806                    .get("row_count")
3807                    .and_then(serde_json::Value::as_i64)
3808                    .unwrap_or(0);
3809                let cols: Vec<String> = t
3810                    .get("columns")
3811                    .and_then(|v| v.as_array())
3812                    .map(|arr| {
3813                        arr.iter()
3814                            .filter_map(|c| {
3815                                let n = c.get("name")?.as_str()?;
3816                                let ty = c.get("type")?.as_str()?;
3817                                Some(format!("`{n}` {ty}"))
3818                            })
3819                            .collect()
3820                    })
3821                    .unwrap_or_default();
3822                let _ = writeln!(md, "| `{name}` | {rows} | {} |\n", cols.join(", "));
3823            }
3824            md.push('\n');
3825            md.push_str("## Related resources\n\n");
3826            for t in &tables {
3827                if let Some(name) = t.get("name").and_then(|v| v.as_str()) {
3828                    let _ = write!(md, "- `hyper://tables/{name}/schema` — JSON schema and row count\n\
3829                         - `hyper://tables/{name}/sample` — first {TABLE_SAMPLE_ROWS} rows as JSON\n\
3830                         - `hyper://tables/{name}/csv-sample` — first {TABLE_CSV_SAMPLE_ROWS} rows as CSV\n");
3831                }
3832            }
3833            md.push('\n');
3834        }
3835
3836        md.push_str(
3837            "## Tool hints\n\n\
3838             - `query(sql)` — read-only SQL (SELECT / WITH / EXPLAIN / SHOW / VALUES).\n\
3839             - `execute(sql)` — DDL/DML (disabled in read-only mode).\n\
3840             - `sample(table, n)` — configurable row sample; the fixed-size\n  \
3841               `hyper://tables/{name}/sample` resource uses n=5.\n\
3842             - `inspect_file(path)` — dry-run schema inference before loading.\n\
3843             - `chart(sql, chart_type, ...)` — render a PNG/SVG from a query.\n\
3844             - `export(sql|table, path, format)` — write to CSV / Parquet / Arrow IPC / .hyper.\n",
3845        );
3846
3847        Ok(ResourceBody::Text {
3848            mime_type: "text/markdown".into(),
3849            content: md,
3850        })
3851    }
3852
3853    /// Build the `hyper://tables/{name}/csv-sample` body: first
3854    /// [`TABLE_CSV_SAMPLE_ROWS`] rows of a table as `text/csv`, with a
3855    /// header row derived from the sample schema.
3856    fn build_csv_sample_body(&self, table: &str) -> Result<ResourceBody, McpError> {
3857        let sample =
3858            self.with_engine(|engine| engine.sample_table(table, TABLE_CSV_SAMPLE_ROWS))?;
3859
3860        // Columns come from the sample's `schema` field in the order Hyper
3861        // reports them; fall back to keys of the first row if that's empty
3862        // (can happen transiently during catalog desync).
3863        let header: Vec<String> = sample
3864            .get("schema")
3865            .and_then(|v| v.as_array())
3866            .map(|cols| {
3867                cols.iter()
3868                    .filter_map(|c| c.get("name").and_then(|n| n.as_str()).map(String::from))
3869                    .collect()
3870            })
3871            .filter(|v: &Vec<String>| !v.is_empty())
3872            .or_else(|| {
3873                sample
3874                    .get("rows")
3875                    .and_then(|v| v.as_array())
3876                    .and_then(|rows| rows.first())
3877                    .and_then(|r| r.as_object())
3878                    .map(|o| o.keys().cloned().collect())
3879            })
3880            .unwrap_or_default();
3881
3882        let mut wtr = csv::Writer::from_writer(Vec::<u8>::new());
3883        if !header.is_empty() {
3884            wtr.write_record(&header).map_err(|e| {
3885                McpError::new(
3886                    ErrorCode::InternalError,
3887                    format!("Failed to write CSV header: {e}"),
3888                )
3889            })?;
3890        }
3891        if let Some(rows) = sample.get("rows").and_then(|v| v.as_array()) {
3892            for row in rows {
3893                let record: Vec<String> = header
3894                    .iter()
3895                    .map(|col| row.get(col).map(value_to_csv_cell).unwrap_or_default())
3896                    .collect();
3897                wtr.write_record(&record).map_err(|e| {
3898                    McpError::new(
3899                        ErrorCode::InternalError,
3900                        format!("Failed to write CSV row: {e}"),
3901                    )
3902                })?;
3903            }
3904        }
3905        let bytes = wtr.into_inner().map_err(|e| {
3906            McpError::new(
3907                ErrorCode::InternalError,
3908                format!("Failed to finalize CSV: {e}"),
3909            )
3910        })?;
3911        let content = String::from_utf8(bytes).map_err(|e| {
3912            McpError::new(
3913                ErrorCode::InternalError,
3914                format!("CSV produced invalid UTF-8: {e}"),
3915            )
3916        })?;
3917
3918        Ok(ResourceBody::Text {
3919            mime_type: "text/csv".into(),
3920            content,
3921        })
3922    }
3923
3924    /// Build a full analysis context block: schema, row count, and a 10-row sample.
3925    /// Returns a markdown-formatted string ready to embed in a prompt message.
3926    fn build_analyze_context(&self, table: &str) -> String {
3927        match self.with_engine(|engine| engine.sample_table(table, 10)) {
3928            Ok(sample) => format!(
3929                "Schema and sample:\n```json\n{}\n```",
3930                serde_json::to_string_pretty(&sample).unwrap_or_else(|_| sample.to_string())
3931            ),
3932            Err(e) => format!("(Could not load table context: {e})"),
3933        }
3934    }
3935
3936    /// Build a brief context block: schema and row count only, no rows.
3937    fn build_brief_context(&self, table: &str) -> String {
3938        match self.with_engine(|engine| engine.sample_table(table, 5)) {
3939            Ok(sample) => format!(
3940                "```json\n{}\n```",
3941                serde_json::to_string_pretty(&sample).unwrap_or_else(|_| sample.to_string())
3942            ),
3943            Err(e) => format!("(Could not load table context: {e})"),
3944        }
3945    }
3946}
3947
3948// --- ServerHandler: tools, prompts, and resources ---
3949
3950#[tool_handler]
3951#[prompt_handler]
3952impl ServerHandler for HyperMcpServer {
3953    fn get_info(&self) -> ServerInfo {
3954        let sql_dialect = "\n\
3955\n\
3956SQL DIALECT — Salesforce Data Cloud SQL (PostgreSQL-compatible with extensions).\n\
3957Key differences from standard PostgreSQL an LLM should know:\n\
3958\n\
3959TYPES\n\
3960- Supported: SMALLINT, INTEGER/INT, BIGINT, REAL/FLOAT4, DOUBLE PRECISION/FLOAT8,\n\
3961  NUMERIC(p,s)/DECIMAL(p,s), BOOLEAN, TEXT, CHAR(n), VARCHAR(n), BYTES,\n\
3962  DATE, TIME, TIMESTAMP, TIMESTAMPTZ, INTERVAL, and arrays of any atomic type\n\
3963- NUMERIC precision > 18 requires .hyper file format version 3 (default in this MCP)\n\
3964- No SERIAL / BIGSERIAL / UUID / JSON / JSONB / geometry types\n\
3965\n\
3966SELECT / QUERY\n\
3967- LIMIT / OFFSET work as in PostgreSQL; TOP N is also accepted\n\
3968- LATERAL is optional: subqueries in FROM always see preceding FROM items implicitly\n\
3969- DISTINCT ON (expr, ...) is supported\n\
3970- FROM clause is optional (can evaluate expressions without a table)\n\
3971- Function calls may appear directly in the FROM list\n\
3972- information_schema and pg_catalog do NOT exist; use the describe/sample tools\n\
3973\n\
3974GROUP BY / AGGREGATION\n\
3975- GROUPING SETS, ROLLUP, CUBE all supported\n\
3976- GROUP BY DISTINCT removes duplicate grouping sets before processing\n\
3977- FILTER (WHERE ...) clause supported on aggregate calls\n\
3978- Ordered-set aggregates: MODE(), PERCENTILE_CONT(), PERCENTILE_DISC() with WITHIN GROUP (ORDER BY ...)\n\
3979- APPROX_COUNT_DISTINCT() for fast approximate cardinality\n\
3980- GROUPING() function identifies which columns are aggregated in GROUPING SETS\n\
3981\n\
3982WINDOW FUNCTIONS\n\
3983- Standard: row_number, rank, dense_rank, percent_rank, cume_dist, ntile, lag, lead,\n\
3984  first_value, last_value, nth_value\n\
3985- Hyper extension: modified_rank() — like rank() but assigns the LOWEST rank on ties\n\
3986- IGNORE NULLS / RESPECT NULLS supported on last_value only\n\
3987- nth_value supports FROM FIRST / FROM LAST\n\
3988- Frame modes: ROWS, RANGE, GROUPS; EXCLUDE CURRENT ROW / GROUP / TIES / NO OTHERS\n\
3989- Window-specific functions do NOT support DISTINCT or ORDER BY in their argument list\n\
3990\n\
3991SET-RETURNING FUNCTIONS (usable in FROM)\n\
3992- unnest(array) — expands an array to rows; supports WITH ORDINALITY\n\
3993- generate_series(start, stop [, step]) — numeric and datetime variants\n\
3994- external(path, format => '...') — reads Parquet, CSV, Iceberg etc. directly from files\n\
3995\n\
3996SET OPERATORS\n\
3997- UNION, INTERSECT, EXCEPT all supported; INTERSECT binds tighter than UNION/EXCEPT\n\
3998- ORDER BY and LIMIT/OFFSET can appear on parenthesized sub-expressions or the final result\n\
3999\n\
4000CTEs\n\
4001- WITH and WITH RECURSIVE both supported\n\
4002- CTEs evaluate once per query execution even if referenced multiple times\n\
4003\n\
4004IDENTIFIERS\n\
4005- Unquoted identifiers are folded to lowercase; double-quote to preserve case or use special chars\n\
4006- Quote names containing uppercase letters, digits at the start, or special characters\n\
4007\n\
4008NOT AVAILABLE IN HYPER (Data 360 / Data Cloud-only features)\n\
4009- AI functions: AI_CLASSIFY, AI_SENTIMENT, and other Data Cloud AI scalar functions\n\
4010- Data Cloud federation / streaming-specific functions\n\
4011\n\
4012Full SQL reference: https://developer.salesforce.com/docs/data/data-cloud-query-guide/references/dc-sql-reference";
4013
4014        let header = if self.read_only {
4015            "HyperDB MCP (read-only): SQL analytics for LLM workflows. Query existing tables, \
4016             sample data, export results. Mutating operations are disabled. \
4017             Call get_readme for a concise tool index, parameter rules, and usage examples."
4018        } else {
4019            "HyperDB MCP: instant SQL analytics for LLM workflows. Load data (CSV, JSON, Parquet, \
4020             Arrow IPC, Apache Iceberg), query with SQL, export results (Parquet, Iceberg, Arrow IPC, \
4021             CSV, Hyper). Use query for SELECT and execute for DDL/DML. \
4022             Call get_readme for a concise tool index, parameter rules, and usage examples."
4023        };
4024        let instructions = format!("{header}{sql_dialect}");
4025        let mut server_info = Implementation::default();
4026        server_info.name = "HyperDB".into();
4027        server_info.title = Some("HyperDB — Hyper SQL Analytics".into());
4028        server_info.version = env!("CARGO_PKG_VERSION").into();
4029        server_info.description = Some(
4030            "MCP server for Tableau Hyper: instant SQL analytics over \
4031             CSV, JSON, Parquet, Arrow IPC, and Apache Iceberg with schema inference, \
4032             partial schema overrides, full-file numeric widening, and \
4033             dry-run file inspection. SQL dialect is PostgreSQL-compatible with \
4034             extensions (Salesforce Data Cloud SQL). Full SQL reference: \
4035             https://developer.salesforce.com/docs/data/data-cloud-query-guide/references/dc-sql-reference/data-cloud-sql-context.html"
4036                .into(),
4037        );
4038
4039        let mut info = ServerInfo::default();
4040        info.instructions = Some(instructions);
4041        info.server_info = server_info;
4042        info.capabilities = ServerCapabilities::builder()
4043            .enable_tools()
4044            .enable_prompts()
4045            .enable_resources()
4046            // Resource subscriptions + list-changed notifications: lets
4047            // clients subscribe to any `hyper://...` URI and receive a
4048            // notification whenever the underlying data has moved,
4049            // without polling.
4050            .enable_resources_subscribe()
4051            .enable_resources_list_changed()
4052            .build();
4053        info
4054    }
4055
4056    async fn initialize(
4057        &self,
4058        request: InitializeRequestParams,
4059        context: RequestContext<RoleServer>,
4060    ) -> Result<InitializeResult, rmcp::ErrorData> {
4061        let name = &request.client_info.name;
4062        let version = &request.client_info.version;
4063        let label = if version.is_empty() {
4064            name.clone()
4065        } else {
4066            format!("{name} {version}")
4067        };
4068        if let Ok(mut guard) = self.client_name.lock() {
4069            *guard = Some(label);
4070        }
4071        context.peer.set_peer_info(request);
4072        Ok(self.get_info())
4073    }
4074
4075    /// Handle a `resources/subscribe` request by recording the calling
4076    /// peer in the registry under the requested URI.
4077    ///
4078    /// MCP does not mandate that the server validate the URI exists
4079    /// beforehand — subscriptions to URIs that don't resolve today (e.g.
4080    /// a saved-query result before `save_query` is called) are allowed
4081    /// and will start delivering notifications as soon as the URI
4082    /// becomes reachable.
4083    async fn subscribe(
4084        &self,
4085        request: SubscribeRequestParams,
4086        context: RequestContext<RoleServer>,
4087    ) -> Result<(), rmcp::ErrorData> {
4088        self.subscriptions.subscribe(&request.uri, context.peer);
4089        Ok(())
4090    }
4091
4092    /// Handle a `resources/unsubscribe` request. Clears every subscription
4093    /// recorded against the URI in this process (see the module-level
4094    /// docs on [`crate::subscriptions`] for why we don't attempt to match
4095    /// peers individually).
4096    async fn unsubscribe(
4097        &self,
4098        request: UnsubscribeRequestParams,
4099        context: RequestContext<RoleServer>,
4100    ) -> Result<(), rmcp::ErrorData> {
4101        self.subscriptions.unsubscribe(&request.uri, &context.peer);
4102        Ok(())
4103    }
4104
4105    /// List MCP resources: the workspace, the tables list, a markdown
4106    /// readme, and three entries per existing table (schema, JSON sample,
4107    /// CSV sample). Calling this lazily starts the engine, so it doubles
4108    /// as a "wake up" signal for MCP clients that pre-fetch resources at
4109    /// connection time.
4110    async fn list_resources(
4111        &self,
4112        _request: Option<PaginatedRequestParams>,
4113        _context: RequestContext<RoleServer>,
4114    ) -> Result<ListResourcesResult, rmcp::ErrorData> {
4115        let mut resources = vec![
4116            RawResource {
4117                uri: "hyper://workspace".into(),
4118                name: "Workspace Info".into(),
4119                title: Some("Hyper Workspace".into()),
4120                description: Some("Workspace mode, table count, total rows, disk usage".into()),
4121                mime_type: Some("application/json".into()),
4122                size: None,
4123                icons: None,
4124                meta: None,
4125            }
4126            .no_annotation(),
4127            RawResource {
4128                uri: "hyper://tables".into(),
4129                name: "All Tables".into(),
4130                title: Some("All Tables".into()),
4131                description: Some("List of all tables with column schemas and row counts".into()),
4132                mime_type: Some("application/json".into()),
4133                size: None,
4134                icons: None,
4135                meta: None,
4136            }
4137            .no_annotation(),
4138            RawResource {
4139                uri: "hyper://readme".into(),
4140                name: "Workspace Readme".into(),
4141                title: Some("HyperDB workspace readme".into()),
4142                description: Some(
4143                    "Markdown overview of the workspace: tables, row counts, related \
4144                     resources, and tool hints for LLMs orienting themselves."
4145                        .into(),
4146                ),
4147                mime_type: Some("text/markdown".into()),
4148                size: None,
4149                icons: None,
4150                meta: None,
4151            }
4152            .no_annotation(),
4153        ];
4154
4155        if let Ok(tables) = self.with_engine(super::engine::Engine::describe_tables) {
4156            // `describe_tables` already excludes `_hyperdb_*` meta-
4157            // tables (see `is_internal_table`), so the resource
4158            // catalog only surfaces user-visible tables.
4159            for table in tables {
4160                if let Some(name) = table.get("name").and_then(|v| v.as_str()) {
4161                    let row_count = table
4162                        .get("row_count")
4163                        .and_then(serde_json::Value::as_i64)
4164                        .unwrap_or(0);
4165                    resources.push(
4166                        RawResource {
4167                            uri: format!("hyper://tables/{name}/schema"),
4168                            name: format!("Schema of {name}"),
4169                            title: Some(format!("{name} schema")),
4170                            description: Some(format!(
4171                                "Column schema and row count ({row_count} rows) for table '{name}'"
4172                            )),
4173                            mime_type: Some("application/json".into()),
4174                            size: None,
4175                            icons: None,
4176                            meta: None,
4177                        }
4178                        .no_annotation(),
4179                    );
4180                    resources.push(
4181                        RawResource {
4182                            uri: format!("hyper://tables/{name}/sample"),
4183                            name: format!("Sample of {name}"),
4184                            title: Some(format!("{name} sample (JSON)")),
4185                            description: Some(format!(
4186                                "First {TABLE_SAMPLE_ROWS} rows of '{name}' as JSON, with schema"
4187                            )),
4188                            mime_type: Some("application/json".into()),
4189                            size: None,
4190                            icons: None,
4191                            meta: None,
4192                        }
4193                        .no_annotation(),
4194                    );
4195                    resources.push(
4196                        RawResource {
4197                            uri: format!("hyper://tables/{name}/csv-sample"),
4198                            name: format!("CSV sample of {name}"),
4199                            title: Some(format!("{name} sample (CSV)")),
4200                            description: Some(format!(
4201                                "First {TABLE_CSV_SAMPLE_ROWS} rows of '{name}' as CSV"
4202                            )),
4203                            mime_type: Some("text/csv".into()),
4204                            size: None,
4205                            icons: None,
4206                            meta: None,
4207                        }
4208                        .no_annotation(),
4209                    );
4210                }
4211            }
4212        }
4213
4214        let store = Arc::clone(&self.saved_queries);
4215        if let Ok(saved) = self.with_saved_query_store(|engine| store.list(engine)) {
4216            for q in saved {
4217                let desc = q
4218                    .description
4219                    .clone()
4220                    .unwrap_or_else(|| format!("Saved read-only SQL query '{}'", q.name));
4221                resources.push(
4222                    RawResource {
4223                        uri: format!("hyper://queries/{}/definition", q.name),
4224                        name: format!("Query: {}", q.name),
4225                        title: Some(format!("{} (definition)", q.name)),
4226                        description: Some(format!("SQL + metadata for saved query '{}'", q.name)),
4227                        mime_type: Some("application/json".into()),
4228                        size: None,
4229                        icons: None,
4230                        meta: None,
4231                    }
4232                    .no_annotation(),
4233                );
4234                resources.push(
4235                    RawResource {
4236                        uri: format!("hyper://queries/{}/result", q.name),
4237                        name: format!("Result: {}", q.name),
4238                        title: Some(format!("{} (result)", q.name)),
4239                        description: Some(format!("{desc} — re-runs on every read")),
4240                        mime_type: Some("application/json".into()),
4241                        size: None,
4242                        icons: None,
4243                        meta: None,
4244                    }
4245                    .no_annotation(),
4246                );
4247            }
4248        }
4249
4250        Ok(ListResourcesResult {
4251            resources,
4252            next_cursor: None,
4253            meta: None,
4254        })
4255    }
4256
4257    /// Advertise URI templates so clients can construct resource URIs for
4258    /// tables they know about without round-tripping `list_resources`.
4259    async fn list_resource_templates(
4260        &self,
4261        _request: Option<PaginatedRequestParams>,
4262        _context: RequestContext<RoleServer>,
4263    ) -> Result<ListResourceTemplatesResult, rmcp::ErrorData> {
4264        let templates = vec![
4265            RawResourceTemplate {
4266                uri_template: "hyper://tables/{name}/schema".into(),
4267                name: "Table Schema".into(),
4268                title: Some("Table Schema".into()),
4269                description: Some(
4270                    "Column schema, types, nullability, and row count for a named table".into(),
4271                ),
4272                mime_type: Some("application/json".into()),
4273                icons: None,
4274            }
4275            .no_annotation(),
4276            RawResourceTemplate {
4277                uri_template: "hyper://tables/{name}/sample".into(),
4278                name: "Table Sample (JSON)".into(),
4279                title: Some("Table Sample".into()),
4280                description: Some(
4281                    "First few rows of a named table as JSON, with schema. For a \
4282                     configurable row count use the `sample` tool instead."
4283                        .into(),
4284                ),
4285                mime_type: Some("application/json".into()),
4286                icons: None,
4287            }
4288            .no_annotation(),
4289            RawResourceTemplate {
4290                uri_template: "hyper://tables/{name}/csv-sample".into(),
4291                name: "Table Sample (CSV)".into(),
4292                title: Some("Table Sample (CSV)".into()),
4293                description: Some(
4294                    "First few rows of a named table as CSV, header-first, for \
4295                     spreadsheet and Pandas consumers."
4296                        .into(),
4297                ),
4298                mime_type: Some("text/csv".into()),
4299                icons: None,
4300            }
4301            .no_annotation(),
4302            RawResourceTemplate {
4303                uri_template: "hyper://queries/{name}/definition".into(),
4304                name: "Saved Query Definition".into(),
4305                title: Some("Saved Query Definition".into()),
4306                description: Some(
4307                    "Stored SQL plus metadata (description, created_at) for a saved \
4308                     query registered via the `save_query` tool."
4309                        .into(),
4310                ),
4311                mime_type: Some("application/json".into()),
4312                icons: None,
4313            }
4314            .no_annotation(),
4315            RawResourceTemplate {
4316                uri_template: "hyper://queries/{name}/result".into(),
4317                name: "Saved Query Result".into(),
4318                title: Some("Saved Query Result".into()),
4319                description: Some(
4320                    "Live result of a saved query. The stored SQL re-runs on every \
4321                     resource read — no caching, always fresh."
4322                        .into(),
4323                ),
4324                mime_type: Some("application/json".into()),
4325                icons: None,
4326            }
4327            .no_annotation(),
4328        ];
4329        Ok(ListResourceTemplatesResult {
4330            resource_templates: templates,
4331            next_cursor: None,
4332            meta: None,
4333        })
4334    }
4335
4336    /// Read a resource by URI. Dispatches via
4337    /// [`HyperMcpServer::resource_body_for_uri`] which returns both the
4338    /// content and its MIME type (JSON for metadata URIs, markdown for the
4339    /// workspace readme, CSV for per-table samples).
4340    async fn read_resource(
4341        &self,
4342        request: ReadResourceRequestParams,
4343        _context: RequestContext<RoleServer>,
4344    ) -> Result<ReadResourceResult, rmcp::ErrorData> {
4345        let uri = &request.uri;
4346        let (mime_type, text) = match self.resource_body_for_uri(uri) {
4347            Ok(Some(body)) => (body.mime_type().to_string(), body.to_text()),
4348            Ok(None) => {
4349                return Err(rmcp::ErrorData::invalid_params(
4350                    format!("Unknown resource URI: {uri}"),
4351                    None,
4352                ));
4353            }
4354            Err(e) => {
4355                // Surface errors as JSON so LLMs can parse `code` / `message` /
4356                // `suggestion` without needing a separate error channel.
4357                let err_val = serde_json::to_value(&e).unwrap_or(Value::String(e.to_string()));
4358                let text =
4359                    serde_json::to_string_pretty(&json!({ "error": err_val })).unwrap_or_default();
4360                ("application/json".into(), text)
4361            }
4362        };
4363
4364        Ok(ReadResourceResult::new(vec![
4365            ResourceContents::TextResourceContents {
4366                uri: uri.clone(),
4367                mime_type: Some(mime_type),
4368                text,
4369                meta: None,
4370            },
4371        ]))
4372    }
4373}
4374
4375/// Validates an `execute` batch up front, before any SQL hits the server.
4376///
4377/// Rules:
4378/// 1. Non-empty array.
4379/// 2. Each element non-empty and not comment-only after stripping comments.
4380/// 3. No element is read-only (steer LLMs to the `query` tool).
4381/// 4. No element is an explicit transaction-control statement
4382///    (BEGIN / COMMIT / ROLLBACK / SAVEPOINT) — the wrapper manages
4383///    the transaction.
4384/// 5. No batch mixes DDL with DML — Hyper aborts mixed transactions with
4385///    SQLSTATE `0A000`.
4386/// 6. Multi-element all-DDL batches are rejected because Hyper auto-commits
4387///    `CREATE` / `DROP` / `ALTER` even inside a transaction, so the
4388///    "atomic" promise would be a lie.
4389///
4390/// `Other`-classified statements (everything not in the explicit
4391/// DDL / DML / read-only / transaction-control sets) are passed
4392/// through untouched — Hyper itself is the final authority on syntax,
4393/// and that includes the "Multi-part queries" / SQLSTATE `0A000`
4394/// error if a caller smuggles a `;`-separated multi-statement string
4395/// into a single array element. The `error.rs` mapping rewrites that
4396/// into an actionable "split into separate array elements" suggestion
4397/// for the LLM.
4398fn validate_execute_batch(stmts: &[String]) -> Result<(), McpError> {
4399    use crate::engine::strip_leading_sql_comments;
4400
4401    if stmts.is_empty() {
4402        return Err(McpError::new(
4403            ErrorCode::InvalidArgument,
4404            "`sql` must be a non-empty array of SQL statements.",
4405        )
4406        .with_suggestion("Pass at least one statement: `sql: [\"INSERT INTO t VALUES (1)\"]`."));
4407    }
4408
4409    let mut has_schema_change = false;
4410    let mut has_data_mutation = false;
4411
4412    for (idx, stmt) in stmts.iter().enumerate() {
4413        if strip_leading_sql_comments(stmt).trim().is_empty() {
4414            return Err(McpError::new(
4415                ErrorCode::InvalidArgument,
4416                format!("`sql[{idx}]` is empty or contains only whitespace/comments."),
4417            )
4418            .with_suggestion("Remove the empty element or replace it with a real statement."));
4419        }
4420        // Classification is comment-aware and only inspects the leading
4421        // keyword, so it returns a meaningful answer even for input
4422        // that happens to be multi-statement. We don't try to detect
4423        // multi-statement input client-side — Hyper's own
4424        // "Multi-part queries" / SQLSTATE 0A000 error is mapped at
4425        // [error.rs:130-134] to a clear "split into separate array
4426        // elements" suggestion, so the LLM gets the same actionable
4427        // hint after one round-trip.
4428        match classify_statement(stmt) {
4429            StatementKind::ReadOnly => {
4430                return Err(McpError::new(
4431                    ErrorCode::SqlError,
4432                    format!(
4433                        "`sql[{idx}]` is a read-only statement; the `execute` tool is for DDL/DML."
4434                    ),
4435                )
4436                .with_suggestion(
4437                    "Use the `query` tool for SELECT/WITH/EXPLAIN/SHOW/VALUES. To read-then-write atomically, fold the read into the write (e.g. `UPDATE … FROM (SELECT …)` or `INSERT … SELECT …`).",
4438                ));
4439            }
4440            StatementKind::TransactionControl => {
4441                // Explicit BEGIN / COMMIT / ROLLBACK / SAVEPOINT inside a
4442                // batch element would defeat atomicity: the `execute`
4443                // tool already opens its own transaction around multi-
4444                // element batches, and a user-issued COMMIT mid-batch
4445                // would commit early. Reject up front with an
4446                // actionable message rather than silently breaking the
4447                // contract.
4448                return Err(McpError::new(
4449                    ErrorCode::InvalidArgument,
4450                    format!(
4451                        "`sql[{idx}]` is a transaction-control statement (BEGIN / COMMIT / ROLLBACK / SAVEPOINT); these are not allowed in `execute` batches."
4452                    ),
4453                )
4454                .with_suggestion(
4455                    "The `execute` tool manages the transaction for you — multi-element arrays already run inside BEGIN/COMMIT. Just pass the DML statements you want to run atomically.",
4456                ));
4457            }
4458            StatementKind::Ddl => has_schema_change = true,
4459            StatementKind::Dml => has_data_mutation = true,
4460            StatementKind::Other => {}
4461        }
4462    }
4463
4464    if has_schema_change && has_data_mutation {
4465        return Err(McpError::new(
4466            ErrorCode::InvalidArgument,
4467            "Cannot mix DDL (CREATE/DROP/ALTER/TRUNCATE/RENAME) with DML (INSERT/UPDATE/DELETE/COPY/MERGE) in one `execute` batch.",
4468        )
4469        .with_suggestion(
4470            "Hyper aborts such transactions with SQLSTATE 0A000. Issue DDL in a separate `execute` call from DML — DDL singletons run as their own auto-commit unit.",
4471        ));
4472    }
4473
4474    if has_schema_change && stmts.len() > 1 {
4475        return Err(McpError::new(
4476            ErrorCode::InvalidArgument,
4477            "Multi-statement DDL batches are not supported.",
4478        )
4479        .with_suggestion(
4480            "Hyper auto-commits CREATE/DROP/ALTER even inside a transaction, so wrapping multiple DDL statements in one `execute` call cannot guarantee atomicity. Issue each DDL as its own single-element `execute` call.",
4481        ));
4482    }
4483
4484    Ok(())
4485}
4486
4487/// Render a JSON cell value into a CSV string. Scalars are emitted in their
4488/// natural form (numbers as `to_string`, booleans as `true` / `false`,
4489/// strings verbatim); objects and arrays are re-encoded as compact JSON so
4490/// the CSV round-trips through re-parsing if needed. `null` becomes the
4491/// empty string, matching typical spreadsheet conventions.
4492fn value_to_csv_cell(v: &Value) -> String {
4493    match v {
4494        Value::Null => String::new(),
4495        Value::Bool(b) => b.to_string(),
4496        Value::Number(n) => n.to_string(),
4497        Value::String(s) => s.clone(),
4498        _ => v.to_string(),
4499    }
4500}
4501
4502/// Heuristic format detection for inline data: if it starts with `[` or `{`
4503/// it's JSON, otherwise CSV. Used when the caller omits the `format` parameter.
4504fn detect_format(data: &str) -> String {
4505    let trimmed = data.trim_start();
4506    if trimmed.starts_with('[') || trimmed.starts_with('{') {
4507        "json".into()
4508    } else {
4509        "csv".into()
4510    }
4511}
4512
4513/// Generate a nanosecond-based suffix to make temp table names unique within
4514/// a session. Not cryptographically random — collisions are astronomically
4515/// unlikely for sequential tool calls.
4516fn rand_suffix() -> String {
4517    use std::time::{SystemTime, UNIX_EPOCH};
4518    let t = SystemTime::now()
4519        .duration_since(UNIX_EPOCH)
4520        .unwrap_or_default();
4521    format!("{}", t.as_nanos() % 1_000_000_000)
4522}
4523
4524/// Build a fully-qualified `"db"."schema"."table"` name. `db` is the
4525/// target alias; `None` means "the primary workspace", which resolves
4526/// via [`Engine::primary_db_name`]. The `public` schema is assumed
4527/// because every tool in this crate materializes into `public`.
4528///
4529/// Note: while `AttachRegistry` now pins `schema_search_path` to the
4530/// primary on every attach (so unqualified local writes succeed too),
4531/// the `copy_query` path still fully-qualifies the target so that
4532/// switching the target to an attached alias requires no SQL
4533/// rewriting — one code path covers local and remote targets.
4534fn qualified_name(engine: &Engine, db: Option<&str>, table: &str) -> String {
4535    let alias = db.map_or_else(|| engine.primary_db_name(), str::to_string);
4536    let escaped_alias = alias.replace('"', "\"\"");
4537    let escaped_table = table.replace('"', "\"\"");
4538    format!("\"{escaped_alias}\".\"public\".\"{escaped_table}\"")
4539}
4540
4541/// `true` if the target resolves to an existing relation, `false` if
4542/// Hyper reports it as missing, `Err` on any other failure. Uses a
4543/// `LIMIT 0` probe rather than a catalog lookup because attached
4544/// databases aren't surfaced by [`Engine::describe_tables`].
4545fn target_exists(engine: &Engine, db: Option<&str>, table: &str) -> Result<bool, McpError> {
4546    let sql = format!(
4547        "SELECT 1 FROM {} LIMIT 0",
4548        qualified_name(engine, db, table)
4549    );
4550    match engine.execute_query_to_json(&sql) {
4551        Ok(_) => Ok(true),
4552        Err(e) => {
4553            let m = e.message.to_lowercase();
4554            let missing = m.contains("does not exist")
4555                || m.contains("undefined table")
4556                || e.message.contains("42P01");
4557            if missing {
4558                Ok(false)
4559            } else {
4560                Err(e)
4561            }
4562        }
4563    }
4564}
4565
4566/// Fetch `COUNT(*)` against the fully-qualified target. Returns 0 if
4567/// the query fails (e.g. after a catalog-invalidation quirk) so the
4568/// tool still returns a result — the caller cares that the copy
4569/// succeeded, not about bookkeeping fidelity.
4570fn count_rows(engine: &Engine, db: Option<&str>, table: &str) -> i64 {
4571    let sql = format!(
4572        "SELECT COUNT(*) AS cnt FROM {}",
4573        qualified_name(engine, db, table)
4574    );
4575    engine
4576        .execute_query_to_json(&sql)
4577        .ok()
4578        .and_then(|rows| {
4579            rows.first()
4580                .and_then(|r| r.get("cnt").and_then(serde_json::Value::as_i64))
4581        })
4582        .unwrap_or(0)
4583}
4584
4585/// Best-effort probe for public-schema tables visible under an alias.
4586/// Returns `Value::Null` on any error so the LLM sees "not available"
4587/// rather than a fabricated zero.
4588fn probe_table_count(engine: &Engine, alias: &str) -> Value {
4589    let escaped_alias = alias.replace('"', "\"\"");
4590    let sql = format!(
4591        "SELECT COUNT(*) AS cnt FROM \"{escaped_alias}\".pg_catalog.pg_tables WHERE schemaname = 'public'"
4592    );
4593    match engine.execute_query_to_json(&sql) {
4594        Ok(rows) => rows
4595            .first()
4596            .and_then(|r| r.get("cnt").and_then(serde_json::Value::as_i64))
4597            .map_or(Value::Null, |n| json!(n)),
4598        Err(_) => Value::Null,
4599    }
4600}
4601
4602/// Validate and convert `copy_query`'s `temp_attach` specs into
4603/// [`AttachRequest`]s. Runs entirely up front (no engine touching)
4604/// so a bad alias or path aborts cleanly before any ATTACH is issued.
4605fn prepare_temp_attachments(
4606    specs: &[AttachSpec],
4607    read_only: bool,
4608) -> Result<Vec<AttachRequest>, McpError> {
4609    let mut out = Vec::with_capacity(specs.len());
4610    for spec in specs {
4611        let writable = spec.writable.unwrap_or(false);
4612        if writable && read_only {
4613            return Err(McpError::new(
4614                ErrorCode::ReadOnlyViolation,
4615                format!(
4616                    "temp_attach for alias '{}' requested writable:true but the server is --read-only",
4617                    spec.alias
4618                ),
4619            ));
4620        }
4621        let on_missing = attach::OnMissing::parse(spec.on_missing.as_deref())?;
4622        if on_missing == attach::OnMissing::Create && !writable {
4623            return Err(McpError::new(
4624                ErrorCode::InvalidArgument,
4625                format!(
4626                    "temp_attach alias '{}' has on_missing='create' but writable is not true — \
4627                     an empty .hyper file that cannot be written to cannot be populated.",
4628                    spec.alias
4629                ),
4630            ));
4631        }
4632        let source = match spec.kind.as_str() {
4633            "local_file" => {
4634                let Some(raw) = spec.path.as_deref() else {
4635                    return Err(McpError::new(
4636                        ErrorCode::InvalidArgument,
4637                        format!("temp_attach alias '{}' requires a 'path'", spec.alias),
4638                    ));
4639                };
4640                let resolved = match on_missing {
4641                    attach::OnMissing::Error => attach::validate_local_path(raw)?,
4642                    attach::OnMissing::Create => attach::validate_local_path_for_create(raw)?,
4643                };
4644                AttachSource::LocalFile { path: resolved }
4645            }
4646            other => {
4647                return Err(McpError::new(
4648                    ErrorCode::InvalidArgument,
4649                    format!(
4650                        "Unsupported temp_attach kind '{other}' for alias '{}'. Only 'local_file' is supported today.",
4651                        spec.alias
4652                    ),
4653                ));
4654            }
4655        };
4656        attach::validate_alias(&spec.alias)?;
4657        out.push(AttachRequest {
4658            alias: spec.alias.clone(),
4659            source,
4660            writable,
4661            on_missing,
4662        });
4663    }
4664    Ok(out)
4665}
4666
4667/// Execute the chosen copy mode against the fully-qualified target
4668/// and return a JSON summary. Extracted from the `copy_query` handler
4669/// so the caller can run it inside the temp-attach cleanup wrapper
4670/// without re-duplicating the match arms.
4671fn perform_copy(
4672    engine: &Engine,
4673    mode: &str,
4674    target_db: Option<&str>,
4675    target_table: &str,
4676    sql_body: &str,
4677) -> Result<Value, McpError> {
4678    let qualified = qualified_name(engine, target_db, target_table);
4679    let exists = target_exists(engine, target_db, target_table)?;
4680    let timer = crate::stats::StatsTimer::start();
4681
4682    match mode {
4683        "create" => {
4684            if exists {
4685                return Err(McpError::new(
4686                    ErrorCode::InvalidArgument,
4687                    format!(
4688                        "Target '{target_table}' already exists. Use mode='append' to add rows or mode='replace' to drop and recreate."
4689                    ),
4690                ));
4691            }
4692            engine.execute_command(&format!("CREATE TABLE {qualified} AS {sql_body}"))?;
4693        }
4694        "append" => {
4695            if !exists {
4696                return Err(McpError::new(
4697                    ErrorCode::InvalidArgument,
4698                    format!(
4699                        "Target '{target_table}' does not exist. Use mode='create' to create it from the query or mode='replace' to drop and recreate."
4700                    ),
4701                ));
4702            }
4703            engine.execute_command(&format!("INSERT INTO {qualified} {sql_body}"))?;
4704        }
4705        "replace" => {
4706            // Hyper auto-commits DDL even inside transactions, so
4707            // DROP+CREATE isn't atomic across the statement boundary
4708            // (same caveat documented on `execute_in_transaction`).
4709            // We still issue them in order — the `IF EXISTS` guard
4710            // prevents an error when the target is absent, and the
4711            // follow-up `CREATE TABLE AS` either succeeds or leaves
4712            // the workspace with a dropped target, which is the
4713            // expected replace semantics.
4714            engine.execute_command(&format!("DROP TABLE IF EXISTS {qualified}"))?;
4715            engine.execute_command(&format!("CREATE TABLE {qualified} AS {sql_body}"))?;
4716        }
4717        other => {
4718            return Err(McpError::new(
4719                ErrorCode::InvalidArgument,
4720                format!("copy_query mode '{other}' is not supported"),
4721            ));
4722        }
4723    }
4724
4725    let elapsed_ms = timer.elapsed_ms();
4726    let row_count = count_rows(engine, target_db, target_table);
4727    Ok(json!({
4728        "target_table": target_table,
4729        "target_database": target_db.unwrap_or(LOCAL_ALIAS),
4730        "mode": mode,
4731        "row_count": row_count,
4732        "stats": { "operation": "copy_query", "elapsed_ms": elapsed_ms },
4733    }))
4734}
4735
4736#[cfg(test)]
4737mod validate_execute_batch_tests {
4738    use super::*;
4739
4740    fn s(v: &[&str]) -> Vec<String> {
4741        v.iter().map(|x| (*x).to_string()).collect()
4742    }
4743
4744    #[test]
4745    fn rejects_empty_array() {
4746        let err = validate_execute_batch(&[]).unwrap_err();
4747        assert_eq!(err.code, ErrorCode::InvalidArgument);
4748    }
4749
4750    #[test]
4751    fn rejects_whitespace_only_element() {
4752        let err = validate_execute_batch(&s(&["   "])).unwrap_err();
4753        assert_eq!(err.code, ErrorCode::InvalidArgument);
4754        assert!(err.message.contains("sql[0]"));
4755        let err = validate_execute_batch(&s(&["INSERT INTO t VALUES (1)", "/* */"])).unwrap_err();
4756        assert!(err.message.contains("sql[1]"));
4757    }
4758
4759    #[test]
4760    fn rejects_read_only_element() {
4761        let err =
4762            validate_execute_batch(&s(&["INSERT INTO t VALUES (1)", "SELECT 1"])).unwrap_err();
4763        assert_eq!(err.code, ErrorCode::SqlError);
4764        assert!(err.message.contains("sql[1]"));
4765    }
4766
4767    #[test]
4768    fn rejects_transaction_control_in_batch() {
4769        // The wrapper manages the transaction; a user-issued COMMIT
4770        // mid-batch would commit early and break atomicity.
4771        for sql in [
4772            "BEGIN",
4773            "COMMIT",
4774            "ROLLBACK",
4775            "SAVEPOINT sp1",
4776            "START TRANSACTION",
4777            "END",
4778            "RELEASE SAVEPOINT sp1",
4779        ] {
4780            let err = validate_execute_batch(&s(&["INSERT INTO t VALUES (1)", sql])).unwrap_err();
4781            assert_eq!(err.code, ErrorCode::InvalidArgument, "for `{sql}`");
4782            assert!(
4783                err.message.contains("transaction-control"),
4784                "for `{sql}`: {}",
4785                err.message
4786            );
4787        }
4788    }
4789
4790    #[test]
4791    fn rejects_transaction_control_singleton() {
4792        // Even a one-element BEGIN-only call is rejected — there's no
4793        // statement following it that could benefit, and the BEGIN
4794        // would leave the connection in an open-transaction state for
4795        // the next caller to inherit.
4796        let err = validate_execute_batch(&s(&["BEGIN"])).unwrap_err();
4797        assert_eq!(err.code, ErrorCode::InvalidArgument);
4798    }
4799
4800    #[test]
4801    fn rejects_ddl_dml_mix() {
4802        let err =
4803            validate_execute_batch(&s(&["CREATE TABLE x (i INT)", "INSERT INTO x VALUES (1)"]))
4804                .unwrap_err();
4805        assert_eq!(err.code, ErrorCode::InvalidArgument);
4806        assert!(err.message.contains("DDL"));
4807    }
4808
4809    #[test]
4810    fn rejects_multi_ddl() {
4811        let err = validate_execute_batch(&s(&["CREATE TABLE a (i INT)", "CREATE TABLE b (j INT)"]))
4812            .unwrap_err();
4813        assert_eq!(err.code, ErrorCode::InvalidArgument);
4814        assert!(err.message.contains("Multi-statement DDL"));
4815    }
4816
4817    #[test]
4818    fn allows_single_ddl() {
4819        validate_execute_batch(&s(&["CREATE TABLE a (i INT)"])).unwrap();
4820    }
4821
4822    #[test]
4823    fn allows_multi_dml() {
4824        validate_execute_batch(&s(&[
4825            "UPDATE settings SET value = 'x' WHERE key = 'k'",
4826            "INSERT INTO settings (key, value) SELECT 'k', 'x' WHERE NOT EXISTS (SELECT 1 FROM settings WHERE key = 'k')",
4827        ]))
4828        .unwrap();
4829    }
4830
4831    #[test]
4832    fn allows_other_kinds() {
4833        // Non-classified statements (e.g. SET, ATTACH) pass through.
4834        // Transaction-control keywords are NOT in this group — see
4835        // `rejects_transaction_control_*` tests.
4836        validate_execute_batch(&s(&["SET schema_search_path = 'mydb'"])).unwrap();
4837    }
4838
4839    #[test]
4840    fn allows_trailing_semicolon() {
4841        validate_execute_batch(&s(&["INSERT INTO t VALUES (1);"])).unwrap();
4842    }
4843}