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