Skip to main content

hyperdb_mcp/
server.rs

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