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