Skip to main content

hyperdb_mcp/
server.rs

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