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