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