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