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. A store drops off this list once its last key is removed — there is no separate registry."
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 in lexicographic
3228    /// key order (atomic).
3229    #[tool(
3230        description = "Destructively read-and-remove the lowest-keyed entry (lexicographic key order, not insertion order) 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."
3231    )]
3232    fn kv_pop(
3233        &self,
3234        Parameters(p): Parameters<KvStoreParams>,
3235    ) -> Result<CallToolResult, rmcp::ErrorData> {
3236        if let Err(e) = self.check_writable("kv_pop") {
3237            return Self::err_content(e);
3238        }
3239        let result = self.with_engine(|engine| {
3240            let db = self.resolve_db(engine, p.database.as_deref(), p.persist, true)?;
3241            let kv = Self::kv_open(engine, db.as_deref(), &p.store)?;
3242            kv.pop().map_err(McpError::from)
3243        });
3244        match result {
3245            Ok(Some((key, value))) => {
3246                Self::ok_content(json!({ "found": true, "key": key, "value": value }))
3247            }
3248            Ok(None) => Self::ok_content(json!({ "found": false })),
3249            Err(e) => Self::err_content(e),
3250        }
3251    }
3252
3253    /// Delete all keys in a scratchpad store.
3254    #[tool(
3255        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."
3256    )]
3257    fn kv_clear(
3258        &self,
3259        Parameters(p): Parameters<KvStoreParams>,
3260    ) -> Result<CallToolResult, rmcp::ErrorData> {
3261        if let Err(e) = self.check_writable("kv_clear") {
3262            return Self::err_content(e);
3263        }
3264        let result = self.with_engine(|engine| {
3265            let db = self.resolve_db(engine, p.database.as_deref(), p.persist, true)?;
3266            let kv = Self::kv_open(engine, db.as_deref(), &p.store)?;
3267            kv.clear().map_err(McpError::from)
3268        });
3269        match result {
3270            Ok(removed) => Self::ok_content(json!({ "store": p.store, "removed": removed })),
3271            Err(e) => Self::err_content(e),
3272        }
3273    }
3274
3275    /// Returns plugin health, workspace info, table count, total rows, disk
3276    /// usage, the backing `hyperd` connection (mode, endpoint, daemon health
3277    /// port), and the list of active directory watchers with their stats.
3278    #[tool(
3279        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."
3280    )]
3281    fn status(&self) -> Result<CallToolResult, rmcp::ErrorData> {
3282        // Use try_lock so `status` never hangs behind a stalled/slow data-plane
3283        // operation on the same session (issue #118). If the engine lock is held
3284        // by another tool call, return a degraded-but-instant response with the
3285        // metadata available without the engine (daemon health, paths, watchers).
3286        //
3287        // `status` is a pure observer: it never initializes the engine. The
3288        // engine is initialized eagerly at server startup (see
3289        // `warm_up_engine`) and lazily by data-plane tools via `with_engine`,
3290        // so by the time a client can call any tool the engine is normally
3291        // already `Some`. If it is still `None` here (eager init failed because
3292        // hyperd was down at startup, or a ConnectionLost just dropped it), we
3293        // report the degraded response honestly rather than blocking to init.
3294        let Ok(guard) = self.engine.try_lock() else {
3295            return Self::ok_content(self.status_degraded());
3296        };
3297        let Some(engine) = guard.as_ref() else {
3298            return Self::ok_content(self.status_degraded());
3299        };
3300        self.ensure_catalog_ready(engine);
3301        let result = engine.status();
3302
3303        match result {
3304            Ok(mut val) => {
3305                if let Some(obj) = val.as_object_mut() {
3306                    obj.insert("engine_busy".into(), json!(false));
3307                    obj.insert("watchers".into(), self.watchers.to_json());
3308                    obj.insert("read_only".into(), json!(self.read_only));
3309                    let attachments: Vec<Value> = self
3310                        .attachments
3311                        .list()
3312                        .iter()
3313                        .map(super::attach::AttachedDb::to_json)
3314                        .collect();
3315                    obj.insert("attachments".into(), Value::Array(attachments));
3316                }
3317                Self::ok_content(val)
3318            }
3319            Err(e) => Self::err_content(e),
3320        }
3321    }
3322
3323    /// Returns a concise LLM-facing README. Stateless — works
3324    /// identically in read-only mode. The text itself documents
3325    /// read-only restrictions, so the tool doesn't branch on
3326    /// `self.read_only`.
3327    #[tool(
3328        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."
3329    )]
3330    #[expect(
3331        clippy::unused_self,
3332        reason = "the #[tool] macro dispatches on &self; signature must match the rest of the tool surface even though this tool is stateless"
3333    )]
3334    #[expect(
3335        clippy::unnecessary_wraps,
3336        reason = "uniform Result<CallToolResult, rmcp::ErrorData> across all tools so the #[tool_router] dispatcher has one signature shape"
3337    )]
3338    fn get_readme(&self) -> Result<CallToolResult, rmcp::ErrorData> {
3339        Ok(CallToolResult::success(vec![Content::text(
3340            crate::readme::README,
3341        )]))
3342    }
3343
3344    /// Attach an additional `.hyper` database under a user-chosen
3345    /// alias so its tables can participate in cross-database queries.
3346    #[tool(
3347        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."
3348    )]
3349    fn attach_database(
3350        &self,
3351        Parameters(params): Parameters<AttachDatabaseParams>,
3352    ) -> Result<CallToolResult, rmcp::ErrorData> {
3353        let writable = params.writable.unwrap_or(false);
3354        if writable {
3355            if let Err(e) = self.check_writable("attach_database(writable)") {
3356                return Self::err_content(e);
3357            }
3358        }
3359        let on_missing = match attach::OnMissing::parse(params.on_missing.as_deref()) {
3360            Ok(v) => v,
3361            Err(e) => return Self::err_content(e),
3362        };
3363        if on_missing == attach::OnMissing::Create && !writable {
3364            return Self::err_content(McpError::new(
3365                ErrorCode::InvalidArgument,
3366                "on_missing='create' requires writable:true — an empty .hyper file that cannot be written to cannot be populated.",
3367            ));
3368        }
3369        let source = match params.kind.as_str() {
3370            "local_file" => {
3371                let Some(raw) = params.path.as_deref() else {
3372                    return Self::err_content(McpError::new(
3373                        ErrorCode::InvalidArgument,
3374                        "kind='local_file' requires a 'path' argument",
3375                    ));
3376                };
3377                let resolved = match on_missing {
3378                    attach::OnMissing::Error => attach::validate_local_path(raw),
3379                    attach::OnMissing::Create => attach::validate_local_path_for_create(raw),
3380                };
3381                match resolved {
3382                    Ok(canonical) => AttachSource::LocalFile { path: canonical },
3383                    Err(e) => return Self::err_content(e),
3384                }
3385            }
3386            other => {
3387                return Self::err_content(McpError::new(
3388                    ErrorCode::InvalidArgument,
3389                    format!(
3390                        "Unsupported attach kind '{other}'. Only 'local_file' is supported today; \
3391                         'tcp' (remote hyperd) and 'grpc' (Data 360) are planned."
3392                    ),
3393                ));
3394            }
3395        };
3396        let req = AttachRequest {
3397            alias: params.alias.clone(),
3398            source,
3399            writable,
3400            on_missing,
3401        };
3402        let registry = self.attachments_handle();
3403        let alias_for_probe = req.alias.clone();
3404        let result = self.with_engine(|engine| {
3405            let entry = registry.attach(engine, req.clone())?;
3406            // Best-effort probe for a table count against the new
3407            // alias so the LLM sees what just came online without a
3408            // separate round-trip. Failures here don't invalidate the
3409            // attach — log and return `null` instead.
3410            let tables_visible = probe_table_count(engine, &alias_for_probe);
3411            Ok(json!({
3412                "alias": entry.alias,
3413                "kind": entry.source.kind_str(),
3414                "source": entry.source.to_json(),
3415                "writable": entry.writable,
3416                "tables_visible": tables_visible,
3417            }))
3418        });
3419        match result {
3420            Ok(val) => Self::ok_content(val),
3421            Err(e) => Self::err_content(e),
3422        }
3423    }
3424
3425    /// Detach a previously attached database.
3426    #[tool(
3427        description = "Detach a database previously registered with attach_database. No-op when the alias is unknown. Returns {detached: true/false}."
3428    )]
3429    fn detach_database(
3430        &self,
3431        Parameters(params): Parameters<DetachDatabaseParams>,
3432    ) -> Result<CallToolResult, rmcp::ErrorData> {
3433        // Canonicalize to the registry's stored form. Aliases are
3434        // lowercased at attach time; watcher `target_db` is also stored
3435        // canonicalized (via `Engine::resolve_target_db`), so an exact
3436        // `==` comparison suffices below.
3437        let alias = params.alias.to_ascii_lowercase();
3438        // Reject if any active watcher targets this alias. Otherwise the
3439        // watcher's pool would keep ingesting into the now-detached
3440        // workspace path; or, if the user re-attached the same alias to
3441        // a different file, into the wrong database. Fixed by stopping
3442        // the watcher first via `unwatch_directory`.
3443        if let Ok(watchers) = self.watchers.watchers.lock() {
3444            let conflict = watchers
3445                .values()
3446                .find(|h| h.target_db.as_deref() == Some(alias.as_str()));
3447            if let Some(h) = conflict {
3448                return Self::err_content(McpError::new(
3449                    ErrorCode::InvalidArgument,
3450                    format!(
3451                        "cannot detach '{alias}': an active watcher on directory '{}' targets it. \
3452                         Call unwatch_directory(\"{}\") first.",
3453                        h.directory.display(),
3454                        h.directory.display()
3455                    ),
3456                ));
3457            }
3458        }
3459        let registry = self.attachments_handle();
3460        let result = self.with_engine(|engine| {
3461            let outcome = registry.detach(engine, &alias)?;
3462            if outcome {
3463                // Drop any cached "_table_catalog exists in this alias"
3464                // probe so a re-attach to a different file or with
3465                // different writability won't reuse a stale entry.
3466                engine.clear_catalog_cache_for(&alias);
3467            }
3468            Ok(outcome)
3469        });
3470        match result {
3471            Ok(detached) => {
3472                Self::ok_content(json!({ "alias": params.alias, "detached": detached }))
3473            }
3474            Err(e) => Self::err_content(e),
3475        }
3476    }
3477
3478    /// List currently attached databases.
3479    ///
3480    /// Named `list_attached_databases` (not `list_attached`) so it
3481    /// sits alongside `attach_database` / `detach_database` as a
3482    /// symmetric verb-database trio. The earlier `list_attached`
3483    /// name broke the pattern and consistently misled LLM callers
3484    /// into hallucinating `list_attached_databases` anyway, so the
3485    /// tool now matches the name the models were already reaching
3486    /// for.
3487    #[tool(
3488        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."
3489    )]
3490    fn list_attached_databases(&self) -> Result<CallToolResult, rmcp::ErrorData> {
3491        let result = self.with_engine(|engine| {
3492            let entries = self.attachments.list();
3493            let attachments: Vec<Value> = entries
3494                .iter()
3495                .map(|entry| {
3496                    let mut obj = entry.to_json();
3497                    let tables_visible = probe_table_count(engine, &entry.alias);
3498                    if let Some(map) = obj.as_object_mut() {
3499                        map.insert("tables_visible".into(), json!(tables_visible));
3500                    }
3501                    obj
3502                })
3503                .collect();
3504            Ok(json!({ "attachments": attachments }))
3505        });
3506        match result {
3507            Ok(val) => Self::ok_content(val),
3508            Err(e) => Self::err_content(e),
3509        }
3510    }
3511
3512    /// Run a SELECT across local + attached databases and land the
3513    /// result into a target table. All three modes (`create`,
3514    /// `append`, `replace`) are explicit — the target's actual
3515    /// existence must match the chosen mode.
3516    #[tool(
3517        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."
3518    )]
3519    fn copy_query(
3520        &self,
3521        Parameters(params): Parameters<CopyQueryParams>,
3522    ) -> Result<CallToolResult, rmcp::ErrorData> {
3523        if let Err(e) = self.check_writable("copy_query") {
3524            return Self::err_content(e);
3525        }
3526        let mode = match params.mode.as_str() {
3527            "create" | "append" | "replace" => params.mode.clone(),
3528            other => {
3529                return Self::err_content(McpError::new(
3530                    ErrorCode::InvalidArgument,
3531                    format!(
3532                        "copy_query mode '{other}' is not supported. Use 'create', 'append', or 'replace'."
3533                    ),
3534                ));
3535            }
3536        };
3537        if !is_read_only_sql(&params.sql) {
3538            return Self::err_content(McpError::new(
3539                ErrorCode::SqlError,
3540                "copy_query's `sql` must be a read-only statement (SELECT / WITH / VALUES). \
3541                 Use the execute tool for raw DDL/DML.",
3542            ));
3543        }
3544        // `target_database = None` and `"local"` both map to the
3545        // primary workspace (unqualified target name). Anything else
3546        // must refer to an attached writable database.
3547        //
3548        // Canonicalize to the registry's lowercase storage form before
3549        // both the registry lookup AND the qualified-SQL build path
3550        // (`perform_copy` → `qualified_name`). Hyper is case-sensitive
3551        // on quoted identifiers; without canonicalization here, a user
3552        // attaching as `"My_DB"` (which the registry stores as
3553        // `"my_db"`) and calling `copy_query(target_database="My_DB")`
3554        // would fail with "database does not exist" once SQL renders.
3555        let target_db_owned = params
3556            .target_database
3557            .as_deref()
3558            .filter(|s| !s.eq_ignore_ascii_case(LOCAL_ALIAS))
3559            .map(str::to_ascii_lowercase);
3560        let target_db = target_db_owned.as_deref();
3561        if let Some(alias) = target_db {
3562            match self.attachments.get(alias) {
3563                None => {
3564                    return Self::err_content(McpError::new(
3565                        ErrorCode::InvalidArgument,
3566                        format!(
3567                            "target_database '{alias}' is not attached. Call attach_database first."
3568                        ),
3569                    ));
3570                }
3571                Some(entry) if !entry.writable => {
3572                    return Self::err_content(McpError::new(
3573                        ErrorCode::InvalidArgument,
3574                        format!(
3575                            "target_database '{alias}' was attached read-only. Re-attach with writable:true to use it as a copy target."
3576                        ),
3577                    ));
3578                }
3579                Some(_) => {}
3580            }
3581        }
3582
3583        // Pre-validate any temp_attach requests *before* we touch the
3584        // engine so a bad spec aborts cleanly without a partial attach.
3585        let temp_specs = params.temp_attach.clone().unwrap_or_default();
3586        let prepared_temps = match prepare_temp_attachments(&temp_specs, self.is_read_only()) {
3587            Ok(v) => v,
3588            Err(e) => return Self::err_content(e),
3589        };
3590
3591        let target_table = params.target_table.clone();
3592        let sql_body = params.sql.clone();
3593        let load_params = serde_json::to_string(&json!({
3594            "mode": mode,
3595            "target_database": params.target_database,
3596            "target_table": target_table,
3597            "sql": Self::fmt_sql(&sql_body),
3598        }))
3599        .ok();
3600
3601        let registry = self.attachments_handle();
3602        let result = self.with_engine(|engine| {
3603            // Phase 1: install temp attachments.
3604            let mut temp_aliases: Vec<String> = Vec::new();
3605            for req in &prepared_temps {
3606                match registry.attach(engine, req.clone()) {
3607                    Ok(entry) => temp_aliases.push(entry.alias),
3608                    Err(e) => {
3609                        // Roll back attachments installed so far.
3610                        for alias in &temp_aliases {
3611                            let _ = registry.detach(engine, alias);
3612                        }
3613                        return Err(e);
3614                    }
3615                }
3616            }
3617
3618            // Phase 2: run the actual copy inside a helper so the
3619            // cleanup path is unified.
3620            let copy_outcome = perform_copy(engine, &mode, target_db, &target_table, &sql_body);
3621
3622            // Phase 3: always detach the temp attachments, even on
3623            // error — they were installed only for the duration of
3624            // this call.
3625            for alias in &temp_aliases {
3626                if let Err(e) = registry.detach(engine, alias) {
3627                    tracing::warn!(
3628                        alias = %alias,
3629                        err = %e.message,
3630                        "failed to detach temp attachment after copy_query",
3631                    );
3632                }
3633            }
3634
3635            // Phase 4: stamp `_table_catalog` inside the same engine
3636            // borrow the copy just ran under. Kept next to the copy
3637            // (rather than spun off in a second `with_engine`) so the
3638            // stub and the data it describes can't diverge — a new
3639            // engine might not even have the catalog materialized yet.
3640            // Skipped when the destination is an attached database
3641            // (their catalog isn't ours) or when the server is bare /
3642            // read-only. `after_ingest_catalog_update` logs WARN on
3643            // failure, matching how `load_file` / `load_data` /
3644            // `execute` register their provenance.
3645            if copy_outcome.is_ok() && target_db.is_none() {
3646                let row_count = copy_outcome
3647                    .as_ref()
3648                    .ok()
3649                    .and_then(|v| v.get("row_count").and_then(serde_json::Value::as_i64));
3650                self.after_ingest_catalog_update(
3651                    engine,
3652                    &target_table,
3653                    "copy_query",
3654                    load_params.as_deref(),
3655                    row_count,
3656                    target_db,
3657                );
3658            }
3659
3660            copy_outcome
3661        });
3662
3663        match result {
3664            Ok(outcome) => {
3665                // Fan out resource updates so subscribers refresh.
3666                if target_db.is_none() {
3667                    self.notify_table_changed(&target_table);
3668                }
3669                self.notify_workspace_changed();
3670                if mode != "append" {
3671                    // `create` / `replace` add or recreate the table,
3672                    // which is a resource-list-changing event.
3673                    self.notify_resource_list_changed();
3674                }
3675                Self::ok_content(outcome)
3676            }
3677            Err(e) => Self::err_content(e),
3678        }
3679    }
3680}
3681
3682// --- Prompts ---
3683
3684#[prompt_router]
3685impl HyperMcpServer {
3686    /// Deep analysis of a single table: schema, sample, column statistics, data quality flags.
3687    #[prompt(
3688        name = "analyze-table",
3689        description = "Deep analysis of a single table: schema, sample, column stats, data quality"
3690    )]
3691    pub async fn analyze_table(
3692        &self,
3693        Parameters(args): Parameters<AnalyzeTableArgs>,
3694    ) -> Vec<PromptMessage> {
3695        let context = self.build_analyze_context(&args.table);
3696        vec![
3697            PromptMessage::new_text(
3698                PromptMessageRole::User,
3699                format!(
3700                    "Analyze the `{}` table thoroughly.\n\n{}\n\nPlease:\n\
3701                    1. Describe each column (what it likely represents based on name and sample values)\n\
3702                    2. Compute basic statistics using the query tool: min/max/avg for numeric columns, distinct count and top values for text columns\n\
3703                    3. Flag any data quality issues: unexpected NULLs, suspicious outliers, inconsistent formats\n\
3704                    4. Summarize your findings in plain English",
3705                    args.table, context
3706                ),
3707            ),
3708            PromptMessage::new_text(
3709                PromptMessageRole::Assistant,
3710                format!(
3711                    "I'll analyze the `{}` table systematically. Let me start by examining the schema and sample, then run targeted queries for statistics and data quality.",
3712                    args.table
3713                ),
3714            ),
3715        ]
3716    }
3717
3718    /// Compare two tables side-by-side: schema alignment, common keys, JOIN suggestions.
3719    #[prompt(
3720        name = "compare-tables",
3721        description = "Compare two tables: schema alignment, common keys, JOIN opportunities"
3722    )]
3723    pub async fn compare_tables(
3724        &self,
3725        Parameters(args): Parameters<CompareTablesArgs>,
3726    ) -> Vec<PromptMessage> {
3727        let ctx_a = self.build_brief_context(&args.table_a);
3728        let ctx_b = self.build_brief_context(&args.table_b);
3729        vec![
3730            PromptMessage::new_text(
3731                PromptMessageRole::User,
3732                format!(
3733                    "Compare these two tables:\n\n## Table A: `{}`\n{}\n\n## Table B: `{}`\n{}\n\nPlease:\n\
3734                    1. Identify columns that appear in both tables (by name or semantic match)\n\
3735                    2. Suggest likely JOIN keys and the JOIN type (inner, left, etc.)\n\
3736                    3. Highlight schema differences (column types, nullability)\n\
3737                    4. Propose 3-5 analytical queries that combine both tables and explain what each reveals",
3738                    args.table_a, ctx_a, args.table_b, ctx_b
3739                ),
3740            ),
3741            PromptMessage::new_text(
3742                PromptMessageRole::Assistant,
3743                format!(
3744                    "I'll compare `{}` and `{}` systematically — schema alignment first, then join keys, then analytical opportunities.",
3745                    args.table_a, args.table_b
3746                ),
3747            ),
3748        ]
3749    }
3750
3751    /// Systematic data quality assessment: nulls, duplicates, cardinality, outliers.
3752    #[prompt(
3753        name = "data-quality",
3754        description = "Systematic data quality assessment: NULL rates, duplicates, low cardinality, outliers"
3755    )]
3756    pub async fn data_quality(
3757        &self,
3758        Parameters(args): Parameters<DataQualityArgs>,
3759    ) -> Vec<PromptMessage> {
3760        let context = self.build_brief_context(&args.table);
3761        vec![
3762            PromptMessage::new_text(
3763                PromptMessageRole::User,
3764                format!(
3765                    "Run a data quality assessment on the `{}` table.\n\n{}\n\nPlease use the query tool to check:\n\
3766                    1. NULL rate per column — run SELECT COUNT(*) FILTER (WHERE col IS NULL) / COUNT(*) for each column\n\
3767                    2. Duplicate rows — compare COUNT(*) vs COUNT(DISTINCT *) or use GROUP BY\n\
3768                    3. Low-cardinality columns — columns with suspiciously few distinct values\n\
3769                    4. Numeric outliers — values more than 3 stddev from the mean\n\
3770                    5. Date sanity — future dates or impossibly old dates in date/timestamp columns\n\n\
3771                    Summarize findings with severity (critical / warning / info) and suggest remediation for each issue.",
3772                    args.table, context
3773                ),
3774            ),
3775            PromptMessage::new_text(
3776                PromptMessageRole::Assistant,
3777                format!(
3778                    "I'll perform a systematic data quality assessment on `{}`. Let me run targeted queries for each check category.",
3779                    args.table
3780                ),
3781            ),
3782        ]
3783    }
3784
3785    /// Propose useful analytical queries for a table, optionally guided by a goal.
3786    #[prompt(
3787        name = "suggest-queries",
3788        description = "Suggest analytical SQL queries for a table, optionally guided by a goal"
3789    )]
3790    pub async fn suggest_queries(
3791        &self,
3792        Parameters(args): Parameters<SuggestQueriesArgs>,
3793    ) -> Vec<PromptMessage> {
3794        let context = self.build_analyze_context(&args.table);
3795        let goal_section = match args.goal.as_deref() {
3796            Some(g) if !g.is_empty() => format!("\n\nSpecific goal: {g}"),
3797            _ => String::new(),
3798        };
3799        vec![
3800            PromptMessage::new_text(
3801                PromptMessageRole::User,
3802                format!(
3803                    "Given the `{}` table:\n\n{}{}\n\nSuggest 5 analytical SQL queries that would be useful for exploring this data. \
3804                    For each query, provide:\n\
3805                    - A descriptive title\n\
3806                    - The exact SQL (valid for Hyper / PostgreSQL-compatible syntax)\n\
3807                    - One sentence explaining what insight it reveals\n\n\
3808                    Prefer queries that use aggregations, GROUP BY, window functions, or CTEs to demonstrate the power of SQL analytics.",
3809                    args.table, context, goal_section
3810                ),
3811            ),
3812            PromptMessage::new_text(
3813                PromptMessageRole::Assistant,
3814                format!(
3815                    "Based on the schema and sample of `{}`, here are 5 analytical queries.",
3816                    args.table
3817                ),
3818            ),
3819        ]
3820    }
3821}
3822
3823/// The payload of a resource read, carrying both MIME type and serialized
3824/// content. Different resources speak different formats (JSON for metadata,
3825/// markdown for human overviews, CSV for spreadsheet consumers), so the
3826/// resource layer needs to pass both along to the MCP client.
3827///
3828/// `Json` variants are pretty-printed when rendered; `Text` variants are
3829/// emitted verbatim. Tests and prompt helpers can still access the
3830/// underlying JSON via [`ResourceBody::as_json`] when it's a JSON payload.
3831#[derive(Debug, Clone)]
3832pub enum ResourceBody {
3833    /// Structured JSON — rendered as pretty-printed `application/json`.
3834    Json(Value),
3835    /// Free-form text with an explicit MIME type (e.g. `text/markdown`,
3836    /// `text/csv`).
3837    Text {
3838        /// IANA media type, e.g. `text/markdown` or `text/csv`.
3839        mime_type: String,
3840        /// The literal text to return to the client, verbatim.
3841        content: String,
3842    },
3843}
3844
3845impl ResourceBody {
3846    /// Return the MIME type this body will be served with.
3847    #[must_use]
3848    pub fn mime_type(&self) -> &str {
3849        match self {
3850            ResourceBody::Json(_) => "application/json",
3851            ResourceBody::Text { mime_type, .. } => mime_type,
3852        }
3853    }
3854
3855    /// Render the body to the text payload the client will receive.
3856    /// JSON variants are pretty-printed; text variants return as-is.
3857    #[must_use]
3858    pub fn to_text(&self) -> String {
3859        match self {
3860            ResourceBody::Json(v) => {
3861                serde_json::to_string_pretty(v).unwrap_or_else(|_| v.to_string())
3862            }
3863            ResourceBody::Text { content, .. } => content.clone(),
3864        }
3865    }
3866
3867    /// Borrow the underlying `Value` when this body is JSON. Useful for
3868    /// tests that want to assert on individual fields without reparsing.
3869    #[must_use]
3870    pub fn as_json(&self) -> Option<&Value> {
3871        match self {
3872            ResourceBody::Json(v) => Some(v),
3873            ResourceBody::Text { .. } => None,
3874        }
3875    }
3876}
3877
3878impl HyperMcpServer {
3879    /// Produce the body for a resource URI without constructing an MCP
3880    /// `RequestContext`. Factored out of [`Self::read_resource`] so tests can
3881    /// exercise URI dispatch without standing up the full MCP runtime.
3882    ///
3883    /// Returns `Ok(None)` if the URI isn't recognized at all (the async trait
3884    /// method surfaces this as an `invalid_params` error to clients).
3885    ///
3886    /// The returned [`ResourceBody`] carries its own MIME type so non-JSON
3887    /// resources (`hyper://readme`, `hyper://tables/{name}/csv-sample`,
3888    /// etc.) can be served verbatim as markdown / CSV.
3889    ///
3890    /// # Errors
3891    ///
3892    /// Propagates any [`McpError`] from the underlying engine call
3893    /// (status probe, table description, CSV sample, saved-query listing,
3894    /// etc.) and bubbles up [`ErrorCode::TableNotFound`] for
3895    /// `hyper://tables/{name}/...` URIs whose table is absent from the
3896    /// workspace.
3897    pub fn resource_body_for_uri(&self, uri: &str) -> Result<Option<ResourceBody>, McpError> {
3898        if uri == "hyper://workspace" {
3899            return self
3900                .with_engine(super::engine::Engine::status)
3901                .map(|v| Some(ResourceBody::Json(v)));
3902        }
3903        if uri == "hyper://tables" {
3904            return self
3905                .with_engine(|engine| {
3906                    engine
3907                        .describe_tables()
3908                        .map(|tables| json!({ "tables": tables }))
3909                })
3910                .map(|v| Some(ResourceBody::Json(v)));
3911        }
3912        if uri == "hyper://readme" {
3913            return self.build_readme_body().map(Some);
3914        }
3915        if uri == "hyper://schema/kv" {
3916            return Ok(Some(ResourceBody::Text {
3917                mime_type: "text/plain".into(),
3918                content: KV_SCHEMA_RESOURCE.to_string(),
3919            }));
3920        }
3921        if let Some(name) = uri
3922            .strip_prefix("hyper://tables/")
3923            .and_then(|rest| rest.strip_suffix("/schema"))
3924        {
3925            let name = name.to_string();
3926            return self
3927                .with_engine(|engine| {
3928                    let tables = engine.describe_tables()?;
3929                    tables
3930                        .into_iter()
3931                        .find(|t| t.get("name").and_then(|v| v.as_str()) == Some(name.as_str()))
3932                        .ok_or_else(|| {
3933                            McpError::new(
3934                                ErrorCode::TableNotFound,
3935                                format!("Table '{name}' does not exist"),
3936                            )
3937                        })
3938                })
3939                .map(|v| Some(ResourceBody::Json(v)));
3940        }
3941        if let Some(name) = uri
3942            .strip_prefix("hyper://tables/")
3943            .and_then(|rest| rest.strip_suffix("/sample"))
3944        {
3945            let name = name.to_string();
3946            return self
3947                .with_engine(|engine| engine.sample_table(&name, TABLE_SAMPLE_ROWS))
3948                .map(|v| Some(ResourceBody::Json(v)));
3949        }
3950        if let Some(name) = uri
3951            .strip_prefix("hyper://tables/")
3952            .and_then(|rest| rest.strip_suffix("/csv-sample"))
3953        {
3954            let name = name.to_string();
3955            return self.build_csv_sample_body(&name).map(Some);
3956        }
3957        if let Some(name) = uri
3958            .strip_prefix("hyper://queries/")
3959            .and_then(|rest| rest.strip_suffix("/definition"))
3960        {
3961            return self.build_saved_query_definition(name).map(Some);
3962        }
3963        if let Some(name) = uri
3964            .strip_prefix("hyper://queries/")
3965            .and_then(|rest| rest.strip_suffix("/result"))
3966        {
3967            return self.build_saved_query_result(name).map(Some);
3968        }
3969        Ok(None)
3970    }
3971
3972    /// Build `hyper://queries/{name}/definition`: the stored SQL plus
3973    /// metadata, as JSON. Returns a `TableNotFound` error when no saved
3974    /// query has that name.
3975    fn build_saved_query_definition(&self, name: &str) -> Result<ResourceBody, McpError> {
3976        let store = Arc::clone(&self.saved_queries);
3977        let name = name.to_string();
3978        let query = self.with_saved_query_store(|engine| store.get(engine, &name))?;
3979        match query {
3980            Some(q) => Ok(ResourceBody::Json(q.to_json())),
3981            None => Err(McpError::new(
3982                ErrorCode::TableNotFound,
3983                format!("No saved query named '{name}'"),
3984            )),
3985        }
3986    }
3987
3988    /// Build `hyper://queries/{name}/result`: re-run the stored SQL on
3989    /// every read and return `{ result: [...], stats: {...} }`. Fresh by
3990    /// default — there is no cache, and the underlying engine is fast
3991    /// enough that caching isn't worth the staleness risk.
3992    fn build_saved_query_result(&self, name: &str) -> Result<ResourceBody, McpError> {
3993        let store = Arc::clone(&self.saved_queries);
3994        let name_owned = name.to_string();
3995        let query = self
3996            .with_saved_query_store(|engine| store.get(engine, &name_owned))?
3997            .ok_or_else(|| {
3998                McpError::new(
3999                    ErrorCode::TableNotFound,
4000                    format!("No saved query named '{name_owned}'"),
4001                )
4002            })?;
4003        let sql = query.sql.clone();
4004        let body = self.with_engine(|engine| {
4005            let timer = crate::stats::StatsTimer::start();
4006            let rows = engine.execute_query_to_json(&sql)?;
4007            let elapsed = timer.elapsed_ms();
4008            let result_size = serde_json::to_string(&rows).map_or(0, |s| s.len() as u64);
4009            let stats = crate::stats::QueryStats {
4010                operation: "saved_query".into(),
4011                rows_returned: rows.len() as u64,
4012                rows_scanned: 0,
4013                elapsed_ms: elapsed,
4014                result_size_bytes: result_size,
4015                tables_touched: vec![],
4016            };
4017            Ok(json!({
4018                "name": query.name,
4019                "sql": Self::fmt_sql(&query.sql),
4020                "result": rows,
4021                "stats": stats.to_json(),
4022            }))
4023        })?;
4024        Ok(ResourceBody::Json(body))
4025    }
4026
4027    /// Produce the list of MCP resources without constructing an MCP
4028    /// `RequestContext`. Factored out of [`Self::list_resources`] for tests.
4029    ///
4030    /// Returns one URI for the workspace, one for the full tables list, one
4031    /// for the workspace readme, one for the KV store schema, three per
4032    /// existing table (schema, sample, csv-sample), and two per saved query
4033    /// (definition, result).
4034    #[must_use]
4035    pub fn list_resource_uris(&self) -> Vec<String> {
4036        let mut uris = vec![
4037            "hyper://workspace".to_string(),
4038            "hyper://tables".to_string(),
4039            "hyper://readme".to_string(),
4040            "hyper://schema/kv".to_string(),
4041        ];
4042        if let Ok(tables) = self.with_engine(super::engine::Engine::describe_tables) {
4043            // `describe_tables` already filters out `_hyperdb_*` meta-
4044            // tables via `is_internal_table`, so any table we see here
4045            // is user-visible.
4046            for table in tables {
4047                if let Some(name) = table.get("name").and_then(|v| v.as_str()) {
4048                    uris.push(format!("hyper://tables/{name}/schema"));
4049                    uris.push(format!("hyper://tables/{name}/sample"));
4050                    uris.push(format!("hyper://tables/{name}/csv-sample"));
4051                }
4052            }
4053        }
4054        let store = Arc::clone(&self.saved_queries);
4055        if let Ok(saved) = self.with_saved_query_store(|engine| store.list(engine)) {
4056            for q in saved {
4057                uris.push(format!("hyper://queries/{}/definition", q.name));
4058                uris.push(format!("hyper://queries/{}/result", q.name));
4059            }
4060        }
4061        uris
4062    }
4063
4064    /// Build the `hyper://readme` markdown body: a human-friendly overview
4065    /// of the current workspace, its tables, and pointers to the other
4066    /// resources and tools an LLM might reach for.
4067    ///
4068    /// Designed to be dropped into an LLM context block so the model can
4069    /// orient itself in a single resource read without first calling
4070    /// `status` and `describe` tools.
4071    fn build_readme_body(&self) -> Result<ResourceBody, McpError> {
4072        let status = self.with_engine(super::engine::Engine::status)?;
4073        let tables = self
4074            .with_engine(super::engine::Engine::describe_tables)
4075            .unwrap_or_default();
4076
4077        let workspace_mode = status
4078            .get("workspace_mode")
4079            .and_then(|v| v.as_str())
4080            .unwrap_or("unknown");
4081        let workspace_path = status
4082            .get("workspace_path")
4083            .and_then(|v| v.as_str())
4084            .unwrap_or("");
4085        let read_only = status
4086            .get("read_only")
4087            .and_then(serde_json::Value::as_bool)
4088            .unwrap_or(false);
4089        let table_count = tables.len();
4090
4091        let mut md = String::new();
4092        md.push_str("# HyperDB workspace\n\n");
4093        let _ = writeln!(
4094            md,
4095            "- Mode: **{workspace_mode}**{}\n",
4096            if read_only { " (read-only)" } else { "" }
4097        );
4098        if !workspace_path.is_empty() {
4099            let _ = writeln!(md, "- Path: `{workspace_path}`\n");
4100        }
4101        let _ = write!(md, "- Tables: **{table_count}**\n\n");
4102
4103        if tables.is_empty() {
4104            md.push_str(
4105                "_No tables loaded yet._ Use the `load_file` or `load_data` tools to \
4106                 ingest CSV / JSON / Parquet / Arrow IPC data; call `inspect_file` \
4107                 first if you're unsure of the schema.\n",
4108            );
4109        } else {
4110            md.push_str("## Tables\n\n");
4111            md.push_str("| Table | Rows | Columns |\n");
4112            md.push_str("|---|---:|---|\n");
4113            for t in &tables {
4114                let name = t.get("name").and_then(|v| v.as_str()).unwrap_or("?");
4115                let rows = t
4116                    .get("row_count")
4117                    .and_then(serde_json::Value::as_i64)
4118                    .unwrap_or(0);
4119                let cols: Vec<String> = t
4120                    .get("columns")
4121                    .and_then(|v| v.as_array())
4122                    .map(|arr| {
4123                        arr.iter()
4124                            .filter_map(|c| {
4125                                let n = c.get("name")?.as_str()?;
4126                                let ty = c.get("type")?.as_str()?;
4127                                Some(format!("`{n}` {ty}"))
4128                            })
4129                            .collect()
4130                    })
4131                    .unwrap_or_default();
4132                let _ = writeln!(md, "| `{name}` | {rows} | {} |\n", cols.join(", "));
4133            }
4134            md.push('\n');
4135            md.push_str("## Related resources\n\n");
4136            for t in &tables {
4137                if let Some(name) = t.get("name").and_then(|v| v.as_str()) {
4138                    let _ = write!(md, "- `hyper://tables/{name}/schema` — JSON schema and row count\n\
4139                         - `hyper://tables/{name}/sample` — first {TABLE_SAMPLE_ROWS} rows as JSON\n\
4140                         - `hyper://tables/{name}/csv-sample` — first {TABLE_CSV_SAMPLE_ROWS} rows as CSV\n");
4141                }
4142            }
4143            md.push('\n');
4144        }
4145
4146        md.push_str(
4147            "## Tool hints\n\n\
4148             - `query(sql)` — read-only SQL (SELECT / WITH / EXPLAIN / SHOW / VALUES).\n\
4149             - `execute(sql)` — DDL/DML (disabled in read-only mode).\n\
4150             - `sample(table, n)` — configurable row sample; the fixed-size\n  \
4151               `hyper://tables/{name}/sample` resource uses n=5.\n\
4152             - `inspect_file(path)` — dry-run schema inference before loading.\n\
4153             - `chart(sql, chart_type, ...)` — render a PNG/SVG from a query.\n\
4154             - `export(sql|table, path, format)` — write to CSV / Parquet / Arrow IPC / .hyper.\n",
4155        );
4156
4157        Ok(ResourceBody::Text {
4158            mime_type: "text/markdown".into(),
4159            content: md,
4160        })
4161    }
4162
4163    /// Build the `hyper://tables/{name}/csv-sample` body: first
4164    /// [`TABLE_CSV_SAMPLE_ROWS`] rows of a table as `text/csv`, with a
4165    /// header row derived from the sample schema.
4166    fn build_csv_sample_body(&self, table: &str) -> Result<ResourceBody, McpError> {
4167        let sample =
4168            self.with_engine(|engine| engine.sample_table(table, TABLE_CSV_SAMPLE_ROWS))?;
4169
4170        // Columns come from the sample's `schema` field in the order Hyper
4171        // reports them; fall back to keys of the first row if that's empty
4172        // (can happen transiently during catalog desync).
4173        let header: Vec<String> = sample
4174            .get("schema")
4175            .and_then(|v| v.as_array())
4176            .map(|cols| {
4177                cols.iter()
4178                    .filter_map(|c| c.get("name").and_then(|n| n.as_str()).map(String::from))
4179                    .collect()
4180            })
4181            .filter(|v: &Vec<String>| !v.is_empty())
4182            .or_else(|| {
4183                sample
4184                    .get("rows")
4185                    .and_then(|v| v.as_array())
4186                    .and_then(|rows| rows.first())
4187                    .and_then(|r| r.as_object())
4188                    .map(|o| o.keys().cloned().collect())
4189            })
4190            .unwrap_or_default();
4191
4192        let mut wtr = csv::Writer::from_writer(Vec::<u8>::new());
4193        if !header.is_empty() {
4194            wtr.write_record(&header).map_err(|e| {
4195                McpError::new(
4196                    ErrorCode::InternalError,
4197                    format!("Failed to write CSV header: {e}"),
4198                )
4199            })?;
4200        }
4201        if let Some(rows) = sample.get("rows").and_then(|v| v.as_array()) {
4202            for row in rows {
4203                let record: Vec<String> = header
4204                    .iter()
4205                    .map(|col| row.get(col).map(value_to_csv_cell).unwrap_or_default())
4206                    .collect();
4207                wtr.write_record(&record).map_err(|e| {
4208                    McpError::new(
4209                        ErrorCode::InternalError,
4210                        format!("Failed to write CSV row: {e}"),
4211                    )
4212                })?;
4213            }
4214        }
4215        let bytes = wtr.into_inner().map_err(|e| {
4216            McpError::new(
4217                ErrorCode::InternalError,
4218                format!("Failed to finalize CSV: {e}"),
4219            )
4220        })?;
4221        let content = String::from_utf8(bytes).map_err(|e| {
4222            McpError::new(
4223                ErrorCode::InternalError,
4224                format!("CSV produced invalid UTF-8: {e}"),
4225            )
4226        })?;
4227
4228        Ok(ResourceBody::Text {
4229            mime_type: "text/csv".into(),
4230            content,
4231        })
4232    }
4233
4234    /// Build a full analysis context block: schema, row count, and a 10-row sample.
4235    /// Returns a markdown-formatted string ready to embed in a prompt message.
4236    fn build_analyze_context(&self, table: &str) -> String {
4237        match self.with_engine(|engine| engine.sample_table(table, 10)) {
4238            Ok(sample) => format!(
4239                "Schema and sample:\n```json\n{}\n```",
4240                serde_json::to_string_pretty(&sample).unwrap_or_else(|_| sample.to_string())
4241            ),
4242            Err(e) => format!("(Could not load table context: {e})"),
4243        }
4244    }
4245
4246    /// Build a brief context block: schema and row count only, no rows.
4247    fn build_brief_context(&self, table: &str) -> String {
4248        match self.with_engine(|engine| engine.sample_table(table, 5)) {
4249            Ok(sample) => format!(
4250                "```json\n{}\n```",
4251                serde_json::to_string_pretty(&sample).unwrap_or_else(|_| sample.to_string())
4252            ),
4253            Err(e) => format!("(Could not load table context: {e})"),
4254        }
4255    }
4256}
4257
4258// --- ServerHandler: tools, prompts, and resources ---
4259
4260#[tool_handler]
4261#[prompt_handler]
4262impl ServerHandler for HyperMcpServer {
4263    fn get_info(&self) -> ServerInfo {
4264        let sql_dialect = "\n\
4265\n\
4266SQL DIALECT — Salesforce Data Cloud SQL (PostgreSQL-compatible with extensions).\n\
4267Key differences from standard PostgreSQL an LLM should know:\n\
4268\n\
4269TYPES\n\
4270- Supported: SMALLINT, INTEGER/INT, BIGINT, REAL/FLOAT4, DOUBLE PRECISION/FLOAT8,\n\
4271  NUMERIC(p,s)/DECIMAL(p,s), BOOLEAN, TEXT, CHAR(n), VARCHAR(n), BYTES,\n\
4272  DATE, TIME, TIMESTAMP, TIMESTAMPTZ, INTERVAL, and arrays of any atomic type\n\
4273- NUMERIC precision > 18 requires .hyper file format version 3 (default in this MCP)\n\
4274- No SERIAL / BIGSERIAL / UUID / JSON / JSONB / geometry types\n\
4275\n\
4276SELECT / QUERY\n\
4277- LIMIT / OFFSET work as in PostgreSQL; TOP N is also accepted\n\
4278- LATERAL is optional: subqueries in FROM always see preceding FROM items implicitly\n\
4279- DISTINCT ON (expr, ...) is supported\n\
4280- FROM clause is optional (can evaluate expressions without a table)\n\
4281- Function calls may appear directly in the FROM list\n\
4282- information_schema and pg_catalog do NOT exist; use the describe/sample tools\n\
4283\n\
4284GROUP BY / AGGREGATION\n\
4285- GROUPING SETS, ROLLUP, CUBE all supported\n\
4286- GROUP BY DISTINCT removes duplicate grouping sets before processing\n\
4287- FILTER (WHERE ...) clause supported on aggregate calls\n\
4288- Ordered-set aggregates: MODE(), PERCENTILE_CONT(), PERCENTILE_DISC() with WITHIN GROUP (ORDER BY ...)\n\
4289- APPROX_COUNT_DISTINCT() for fast approximate cardinality\n\
4290- GROUPING() function identifies which columns are aggregated in GROUPING SETS\n\
4291\n\
4292WINDOW FUNCTIONS\n\
4293- Standard: row_number, rank, dense_rank, percent_rank, cume_dist, ntile, lag, lead,\n\
4294  first_value, last_value, nth_value\n\
4295- Hyper extension: modified_rank() — like rank() but assigns the LOWEST rank on ties\n\
4296- IGNORE NULLS / RESPECT NULLS supported on last_value only\n\
4297- nth_value supports FROM FIRST / FROM LAST\n\
4298- Frame modes: ROWS, RANGE, GROUPS; EXCLUDE CURRENT ROW / GROUP / TIES / NO OTHERS\n\
4299- Window-specific functions do NOT support DISTINCT or ORDER BY in their argument list\n\
4300\n\
4301SET-RETURNING FUNCTIONS (usable in FROM)\n\
4302- unnest(array) — expands an array to rows; supports WITH ORDINALITY\n\
4303- generate_series(start, stop [, step]) — numeric and datetime variants\n\
4304- external(path, format => '...') — reads Parquet, CSV, Iceberg etc. directly from files\n\
4305\n\
4306SET OPERATORS\n\
4307- UNION, INTERSECT, EXCEPT all supported; INTERSECT binds tighter than UNION/EXCEPT\n\
4308- ORDER BY and LIMIT/OFFSET can appear on parenthesized sub-expressions or the final result\n\
4309\n\
4310CTEs\n\
4311- WITH and WITH RECURSIVE both supported\n\
4312- CTEs evaluate once per query execution even if referenced multiple times\n\
4313\n\
4314IDENTIFIERS\n\
4315- Unquoted identifiers are folded to lowercase; double-quote to preserve case or use special chars\n\
4316- Quote names containing uppercase letters, digits at the start, or special characters\n\
4317\n\
4318NOT AVAILABLE IN HYPER (Data 360 / Data Cloud-only features)\n\
4319- AI functions: AI_CLASSIFY, AI_SENTIMENT, and other Data Cloud AI scalar functions\n\
4320- Data Cloud federation / streaming-specific functions\n\
4321\n\
4322Full SQL reference: https://developer.salesforce.com/docs/data/data-cloud-query-guide/references/dc-sql-reference";
4323
4324        let header = if self.read_only {
4325            "HyperDB MCP (read-only): SQL analytics for LLM workflows. Query existing tables, \
4326             sample data, export results. Mutating operations are disabled. \
4327             Call get_readme for a concise tool index, parameter rules, and usage examples."
4328        } else {
4329            "HyperDB MCP: instant SQL analytics for LLM workflows. Load data (CSV, JSON, Parquet, \
4330             Arrow IPC, Apache Iceberg), query with SQL, export results (Parquet, Iceberg, Arrow IPC, \
4331             CSV, Hyper). Use query for SELECT and execute for DDL/DML. \
4332             Call get_readme for a concise tool index, parameter rules, and usage examples."
4333        };
4334        let instructions = format!("{header}{sql_dialect}");
4335        let mut server_info = Implementation::default();
4336        server_info.name = "HyperDB".into();
4337        server_info.title = Some("HyperDB — Hyper SQL Analytics".into());
4338        server_info.version = env!("CARGO_PKG_VERSION").into();
4339        server_info.description = Some(
4340            "MCP server for Tableau Hyper: instant SQL analytics over \
4341             CSV, JSON, Parquet, Arrow IPC, and Apache Iceberg with schema inference, \
4342             partial schema overrides, full-file numeric widening, and \
4343             dry-run file inspection. SQL dialect is PostgreSQL-compatible with \
4344             extensions (Salesforce Data Cloud SQL). Full SQL reference: \
4345             https://developer.salesforce.com/docs/data/data-cloud-query-guide/references/dc-sql-reference/data-cloud-sql-context.html"
4346                .into(),
4347        );
4348
4349        let mut info = ServerInfo::default();
4350        info.instructions = Some(instructions);
4351        info.server_info = server_info;
4352        info.capabilities = ServerCapabilities::builder()
4353            .enable_tools()
4354            .enable_prompts()
4355            .enable_resources()
4356            // Resource subscriptions + list-changed notifications: lets
4357            // clients subscribe to any `hyper://...` URI and receive a
4358            // notification whenever the underlying data has moved,
4359            // without polling.
4360            .enable_resources_subscribe()
4361            .enable_resources_list_changed()
4362            .build();
4363        info
4364    }
4365
4366    async fn initialize(
4367        &self,
4368        request: InitializeRequestParams,
4369        context: RequestContext<RoleServer>,
4370    ) -> Result<InitializeResult, rmcp::ErrorData> {
4371        let name = &request.client_info.name;
4372        let version = &request.client_info.version;
4373        let label = if version.is_empty() {
4374            name.clone()
4375        } else {
4376            format!("{name} {version}")
4377        };
4378        if let Ok(mut guard) = self.client_name.lock() {
4379            *guard = Some(label);
4380        }
4381        context.peer.set_peer_info(request);
4382        Ok(self.get_info())
4383    }
4384
4385    /// Handle a `resources/subscribe` request by recording the calling
4386    /// peer in the registry under the requested URI.
4387    ///
4388    /// MCP does not mandate that the server validate the URI exists
4389    /// beforehand — subscriptions to URIs that don't resolve today (e.g.
4390    /// a saved-query result before `save_query` is called) are allowed
4391    /// and will start delivering notifications as soon as the URI
4392    /// becomes reachable.
4393    async fn subscribe(
4394        &self,
4395        request: SubscribeRequestParams,
4396        context: RequestContext<RoleServer>,
4397    ) -> Result<(), rmcp::ErrorData> {
4398        self.subscriptions.subscribe(&request.uri, context.peer);
4399        Ok(())
4400    }
4401
4402    /// Handle a `resources/unsubscribe` request. Clears every subscription
4403    /// recorded against the URI in this process (see the module-level
4404    /// docs on [`crate::subscriptions`] for why we don't attempt to match
4405    /// peers individually).
4406    async fn unsubscribe(
4407        &self,
4408        request: UnsubscribeRequestParams,
4409        context: RequestContext<RoleServer>,
4410    ) -> Result<(), rmcp::ErrorData> {
4411        self.subscriptions.unsubscribe(&request.uri, &context.peer);
4412        Ok(())
4413    }
4414
4415    /// List MCP resources: the workspace, the tables list, a markdown
4416    /// readme, the KV store schema, and three entries per existing table
4417    /// (schema, JSON sample, CSV sample). Calling this lazily starts the
4418    /// engine, so it doubles as a "wake up" signal for MCP clients that
4419    /// pre-fetch resources at connection time.
4420    async fn list_resources(
4421        &self,
4422        _request: Option<PaginatedRequestParams>,
4423        _context: RequestContext<RoleServer>,
4424    ) -> Result<ListResourcesResult, rmcp::ErrorData> {
4425        let mut resources = vec![
4426            RawResource {
4427                uri: "hyper://workspace".into(),
4428                name: "Workspace Info".into(),
4429                title: Some("Hyper Workspace".into()),
4430                description: Some("Workspace mode, table count, total rows, disk usage".into()),
4431                mime_type: Some("application/json".into()),
4432                size: None,
4433                icons: None,
4434                meta: None,
4435            }
4436            .no_annotation(),
4437            RawResource {
4438                uri: "hyper://tables".into(),
4439                name: "All Tables".into(),
4440                title: Some("All Tables".into()),
4441                description: Some("List of all tables with column schemas and row counts".into()),
4442                mime_type: Some("application/json".into()),
4443                size: None,
4444                icons: None,
4445                meta: None,
4446            }
4447            .no_annotation(),
4448            RawResource {
4449                uri: "hyper://readme".into(),
4450                name: "Workspace Readme".into(),
4451                title: Some("HyperDB workspace readme".into()),
4452                description: Some(
4453                    "Markdown overview of the workspace: tables, row counts, related \
4454                     resources, and tool hints for LLMs orienting themselves."
4455                        .into(),
4456                ),
4457                mime_type: Some("text/markdown".into()),
4458                size: None,
4459                icons: None,
4460                meta: None,
4461            }
4462            .no_annotation(),
4463            RawResource {
4464                uri: "hyper://schema/kv".into(),
4465                name: "KV store schema".into(),
4466                title: Some("Key-value scratchpad schema".into()),
4467                description: Some(
4468                    "Schema of the _hyperdb_kv_store table backing the kv_* tools, the \
4469                     ephemeral-vs-persistent durability rule, and the LEFT JOIN enrichment \
4470                     pattern for joining KV metadata onto analytical tables."
4471                        .into(),
4472                ),
4473                mime_type: Some("text/plain".into()),
4474                size: None,
4475                icons: None,
4476                meta: None,
4477            }
4478            .no_annotation(),
4479        ];
4480
4481        if let Ok(tables) = self.with_engine(super::engine::Engine::describe_tables) {
4482            // `describe_tables` already excludes `_hyperdb_*` meta-
4483            // tables (see `is_internal_table`), so the resource
4484            // catalog only surfaces user-visible tables.
4485            for table in tables {
4486                if let Some(name) = table.get("name").and_then(|v| v.as_str()) {
4487                    let row_count = table
4488                        .get("row_count")
4489                        .and_then(serde_json::Value::as_i64)
4490                        .unwrap_or(0);
4491                    resources.push(
4492                        RawResource {
4493                            uri: format!("hyper://tables/{name}/schema"),
4494                            name: format!("Schema of {name}"),
4495                            title: Some(format!("{name} schema")),
4496                            description: Some(format!(
4497                                "Column schema and row count ({row_count} rows) for table '{name}'"
4498                            )),
4499                            mime_type: Some("application/json".into()),
4500                            size: None,
4501                            icons: None,
4502                            meta: None,
4503                        }
4504                        .no_annotation(),
4505                    );
4506                    resources.push(
4507                        RawResource {
4508                            uri: format!("hyper://tables/{name}/sample"),
4509                            name: format!("Sample of {name}"),
4510                            title: Some(format!("{name} sample (JSON)")),
4511                            description: Some(format!(
4512                                "First {TABLE_SAMPLE_ROWS} rows of '{name}' as JSON, with schema"
4513                            )),
4514                            mime_type: Some("application/json".into()),
4515                            size: None,
4516                            icons: None,
4517                            meta: None,
4518                        }
4519                        .no_annotation(),
4520                    );
4521                    resources.push(
4522                        RawResource {
4523                            uri: format!("hyper://tables/{name}/csv-sample"),
4524                            name: format!("CSV sample of {name}"),
4525                            title: Some(format!("{name} sample (CSV)")),
4526                            description: Some(format!(
4527                                "First {TABLE_CSV_SAMPLE_ROWS} rows of '{name}' as CSV"
4528                            )),
4529                            mime_type: Some("text/csv".into()),
4530                            size: None,
4531                            icons: None,
4532                            meta: None,
4533                        }
4534                        .no_annotation(),
4535                    );
4536                }
4537            }
4538        }
4539
4540        let store = Arc::clone(&self.saved_queries);
4541        if let Ok(saved) = self.with_saved_query_store(|engine| store.list(engine)) {
4542            for q in saved {
4543                let desc = q
4544                    .description
4545                    .clone()
4546                    .unwrap_or_else(|| format!("Saved read-only SQL query '{}'", q.name));
4547                resources.push(
4548                    RawResource {
4549                        uri: format!("hyper://queries/{}/definition", q.name),
4550                        name: format!("Query: {}", q.name),
4551                        title: Some(format!("{} (definition)", q.name)),
4552                        description: Some(format!("SQL + metadata for saved query '{}'", q.name)),
4553                        mime_type: Some("application/json".into()),
4554                        size: None,
4555                        icons: None,
4556                        meta: None,
4557                    }
4558                    .no_annotation(),
4559                );
4560                resources.push(
4561                    RawResource {
4562                        uri: format!("hyper://queries/{}/result", q.name),
4563                        name: format!("Result: {}", q.name),
4564                        title: Some(format!("{} (result)", q.name)),
4565                        description: Some(format!("{desc} — re-runs on every read")),
4566                        mime_type: Some("application/json".into()),
4567                        size: None,
4568                        icons: None,
4569                        meta: None,
4570                    }
4571                    .no_annotation(),
4572                );
4573            }
4574        }
4575
4576        Ok(ListResourcesResult {
4577            resources,
4578            next_cursor: None,
4579            meta: None,
4580        })
4581    }
4582
4583    /// Advertise URI templates so clients can construct resource URIs for
4584    /// tables they know about without round-tripping `list_resources`.
4585    async fn list_resource_templates(
4586        &self,
4587        _request: Option<PaginatedRequestParams>,
4588        _context: RequestContext<RoleServer>,
4589    ) -> Result<ListResourceTemplatesResult, rmcp::ErrorData> {
4590        let templates = vec![
4591            RawResourceTemplate {
4592                uri_template: "hyper://tables/{name}/schema".into(),
4593                name: "Table Schema".into(),
4594                title: Some("Table Schema".into()),
4595                description: Some(
4596                    "Column schema, types, nullability, and row count for a named table".into(),
4597                ),
4598                mime_type: Some("application/json".into()),
4599                icons: None,
4600            }
4601            .no_annotation(),
4602            RawResourceTemplate {
4603                uri_template: "hyper://tables/{name}/sample".into(),
4604                name: "Table Sample (JSON)".into(),
4605                title: Some("Table Sample".into()),
4606                description: Some(
4607                    "First few rows of a named table as JSON, with schema. For a \
4608                     configurable row count use the `sample` tool instead."
4609                        .into(),
4610                ),
4611                mime_type: Some("application/json".into()),
4612                icons: None,
4613            }
4614            .no_annotation(),
4615            RawResourceTemplate {
4616                uri_template: "hyper://tables/{name}/csv-sample".into(),
4617                name: "Table Sample (CSV)".into(),
4618                title: Some("Table Sample (CSV)".into()),
4619                description: Some(
4620                    "First few rows of a named table as CSV, header-first, for \
4621                     spreadsheet and Pandas consumers."
4622                        .into(),
4623                ),
4624                mime_type: Some("text/csv".into()),
4625                icons: None,
4626            }
4627            .no_annotation(),
4628            RawResourceTemplate {
4629                uri_template: "hyper://queries/{name}/definition".into(),
4630                name: "Saved Query Definition".into(),
4631                title: Some("Saved Query Definition".into()),
4632                description: Some(
4633                    "Stored SQL plus metadata (description, created_at) for a saved \
4634                     query registered via the `save_query` tool."
4635                        .into(),
4636                ),
4637                mime_type: Some("application/json".into()),
4638                icons: None,
4639            }
4640            .no_annotation(),
4641            RawResourceTemplate {
4642                uri_template: "hyper://queries/{name}/result".into(),
4643                name: "Saved Query Result".into(),
4644                title: Some("Saved Query Result".into()),
4645                description: Some(
4646                    "Live result of a saved query. The stored SQL re-runs on every \
4647                     resource read — no caching, always fresh."
4648                        .into(),
4649                ),
4650                mime_type: Some("application/json".into()),
4651                icons: None,
4652            }
4653            .no_annotation(),
4654        ];
4655        Ok(ListResourceTemplatesResult {
4656            resource_templates: templates,
4657            next_cursor: None,
4658            meta: None,
4659        })
4660    }
4661
4662    /// Read a resource by URI. Dispatches via
4663    /// [`HyperMcpServer::resource_body_for_uri`] which returns both the
4664    /// content and its MIME type (JSON for metadata URIs, markdown for the
4665    /// workspace readme, CSV for per-table samples).
4666    async fn read_resource(
4667        &self,
4668        request: ReadResourceRequestParams,
4669        _context: RequestContext<RoleServer>,
4670    ) -> Result<ReadResourceResult, rmcp::ErrorData> {
4671        let uri = &request.uri;
4672        let (mime_type, text) = match self.resource_body_for_uri(uri) {
4673            Ok(Some(body)) => (body.mime_type().to_string(), body.to_text()),
4674            Ok(None) => {
4675                return Err(rmcp::ErrorData::invalid_params(
4676                    format!("Unknown resource URI: {uri}"),
4677                    None,
4678                ));
4679            }
4680            Err(e) => {
4681                // Surface errors as JSON so LLMs can parse `code` / `message` /
4682                // `suggestion` without needing a separate error channel.
4683                let err_val = serde_json::to_value(&e).unwrap_or(Value::String(e.to_string()));
4684                let text =
4685                    serde_json::to_string_pretty(&json!({ "error": err_val })).unwrap_or_default();
4686                ("application/json".into(), text)
4687            }
4688        };
4689
4690        Ok(ReadResourceResult::new(vec![
4691            ResourceContents::TextResourceContents {
4692                uri: uri.clone(),
4693                mime_type: Some(mime_type),
4694                text,
4695                meta: None,
4696            },
4697        ]))
4698    }
4699}
4700
4701/// Validates an `execute` batch up front, before any SQL hits the server.
4702///
4703/// Rules:
4704/// 1. Non-empty array.
4705/// 2. Each element non-empty and not comment-only after stripping comments.
4706/// 3. No element is read-only (steer LLMs to the `query` tool).
4707/// 4. No element is an explicit transaction-control statement
4708///    (BEGIN / COMMIT / ROLLBACK / SAVEPOINT) — the wrapper manages
4709///    the transaction.
4710/// 5. No batch mixes DDL with DML — Hyper aborts mixed transactions with
4711///    SQLSTATE `0A000`.
4712/// 6. Multi-element all-DDL batches are rejected because Hyper auto-commits
4713///    `CREATE` / `DROP` / `ALTER` even inside a transaction, so the
4714///    "atomic" promise would be a lie.
4715///
4716/// `Other`-classified statements (everything not in the explicit
4717/// DDL / DML / read-only / transaction-control sets) are passed
4718/// through untouched — Hyper itself is the final authority on syntax,
4719/// and that includes the "Multi-part queries" / SQLSTATE `0A000`
4720/// error if a caller smuggles a `;`-separated multi-statement string
4721/// into a single array element. The `error.rs` mapping rewrites that
4722/// into an actionable "split into separate array elements" suggestion
4723/// for the LLM.
4724fn validate_execute_batch(stmts: &[String]) -> Result<(), McpError> {
4725    use crate::engine::strip_leading_sql_comments;
4726
4727    if stmts.is_empty() {
4728        return Err(McpError::new(
4729            ErrorCode::InvalidArgument,
4730            "`sql` must be a non-empty array of SQL statements.",
4731        )
4732        .with_suggestion("Pass at least one statement: `sql: [\"INSERT INTO t VALUES (1)\"]`."));
4733    }
4734
4735    let mut has_schema_change = false;
4736    let mut has_data_mutation = false;
4737
4738    for (idx, stmt) in stmts.iter().enumerate() {
4739        if strip_leading_sql_comments(stmt).trim().is_empty() {
4740            return Err(McpError::new(
4741                ErrorCode::InvalidArgument,
4742                format!("`sql[{idx}]` is empty or contains only whitespace/comments."),
4743            )
4744            .with_suggestion("Remove the empty element or replace it with a real statement."));
4745        }
4746        // Classification is comment-aware and only inspects the leading
4747        // keyword, so it returns a meaningful answer even for input
4748        // that happens to be multi-statement. We don't try to detect
4749        // multi-statement input client-side — Hyper's own
4750        // "Multi-part queries" / SQLSTATE 0A000 error is mapped at
4751        // [error.rs:130-134] to a clear "split into separate array
4752        // elements" suggestion, so the LLM gets the same actionable
4753        // hint after one round-trip.
4754        match classify_statement(stmt) {
4755            StatementKind::ReadOnly => {
4756                return Err(McpError::new(
4757                    ErrorCode::SqlError,
4758                    format!(
4759                        "`sql[{idx}]` is a read-only statement; the `execute` tool is for DDL/DML."
4760                    ),
4761                )
4762                .with_suggestion(
4763                    "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 …`).",
4764                ));
4765            }
4766            StatementKind::TransactionControl => {
4767                // Explicit BEGIN / COMMIT / ROLLBACK / SAVEPOINT inside a
4768                // batch element would defeat atomicity: the `execute`
4769                // tool already opens its own transaction around multi-
4770                // element batches, and a user-issued COMMIT mid-batch
4771                // would commit early. Reject up front with an
4772                // actionable message rather than silently breaking the
4773                // contract.
4774                return Err(McpError::new(
4775                    ErrorCode::InvalidArgument,
4776                    format!(
4777                        "`sql[{idx}]` is a transaction-control statement (BEGIN / COMMIT / ROLLBACK / SAVEPOINT); these are not allowed in `execute` batches."
4778                    ),
4779                )
4780                .with_suggestion(
4781                    "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.",
4782                ));
4783            }
4784            StatementKind::Ddl => has_schema_change = true,
4785            StatementKind::Dml => has_data_mutation = true,
4786            StatementKind::Other => {}
4787        }
4788    }
4789
4790    if has_schema_change && has_data_mutation {
4791        return Err(McpError::new(
4792            ErrorCode::InvalidArgument,
4793            "Cannot mix DDL (CREATE/DROP/ALTER/TRUNCATE/RENAME) with DML (INSERT/UPDATE/DELETE/COPY/MERGE) in one `execute` batch.",
4794        )
4795        .with_suggestion(
4796            "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.",
4797        ));
4798    }
4799
4800    if has_schema_change && stmts.len() > 1 {
4801        return Err(McpError::new(
4802            ErrorCode::InvalidArgument,
4803            "Multi-statement DDL batches are not supported.",
4804        )
4805        .with_suggestion(
4806            "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.",
4807        ));
4808    }
4809
4810    Ok(())
4811}
4812
4813/// Render a JSON cell value into a CSV string. Scalars are emitted in their
4814/// natural form (numbers as `to_string`, booleans as `true` / `false`,
4815/// strings verbatim); objects and arrays are re-encoded as compact JSON so
4816/// the CSV round-trips through re-parsing if needed. `null` becomes the
4817/// empty string, matching typical spreadsheet conventions.
4818fn value_to_csv_cell(v: &Value) -> String {
4819    match v {
4820        Value::Null => String::new(),
4821        Value::Bool(b) => b.to_string(),
4822        Value::Number(n) => n.to_string(),
4823        Value::String(s) => s.clone(),
4824        _ => v.to_string(),
4825    }
4826}
4827
4828/// Heuristic format detection for inline data: if it starts with `[` or `{`
4829/// it's JSON, otherwise CSV. Used when the caller omits the `format` parameter.
4830fn detect_format(data: &str) -> String {
4831    let trimmed = data.trim_start();
4832    if trimmed.starts_with('[') || trimmed.starts_with('{') {
4833        "json".into()
4834    } else {
4835        "csv".into()
4836    }
4837}
4838
4839/// Generate a nanosecond-based suffix to make temp table names unique within
4840/// a session. Not cryptographically random — collisions are astronomically
4841/// unlikely for sequential tool calls.
4842fn rand_suffix() -> String {
4843    use std::time::{SystemTime, UNIX_EPOCH};
4844    let t = SystemTime::now()
4845        .duration_since(UNIX_EPOCH)
4846        .unwrap_or_default();
4847    format!("{}", t.as_nanos() % 1_000_000_000)
4848}
4849
4850/// Build a fully-qualified `"db"."schema"."table"` name. `db` is the
4851/// target alias; `None` means "the primary workspace", which resolves
4852/// via [`Engine::primary_db_name`]. The `public` schema is assumed
4853/// because every tool in this crate materializes into `public`.
4854///
4855/// Note: while `AttachRegistry` now pins `schema_search_path` to the
4856/// primary on every attach (so unqualified local writes succeed too),
4857/// the `copy_query` path still fully-qualifies the target so that
4858/// switching the target to an attached alias requires no SQL
4859/// rewriting — one code path covers local and remote targets.
4860fn qualified_name(engine: &Engine, db: Option<&str>, table: &str) -> String {
4861    let alias = db.map_or_else(|| engine.primary_db_name(), str::to_string);
4862    let escaped_alias = alias.replace('"', "\"\"");
4863    let escaped_table = table.replace('"', "\"\"");
4864    format!("\"{escaped_alias}\".\"public\".\"{escaped_table}\"")
4865}
4866
4867/// `true` if the target resolves to an existing relation, `false` if
4868/// Hyper reports it as missing, `Err` on any other failure. Uses a
4869/// `LIMIT 0` probe rather than a catalog lookup because attached
4870/// databases aren't surfaced by [`Engine::describe_tables`].
4871fn target_exists(engine: &Engine, db: Option<&str>, table: &str) -> Result<bool, McpError> {
4872    let sql = format!(
4873        "SELECT 1 FROM {} LIMIT 0",
4874        qualified_name(engine, db, table)
4875    );
4876    match engine.execute_query_to_json(&sql) {
4877        Ok(_) => Ok(true),
4878        Err(e) => {
4879            let m = e.message.to_lowercase();
4880            let missing = m.contains("does not exist")
4881                || m.contains("undefined table")
4882                || e.message.contains("42P01");
4883            if missing {
4884                Ok(false)
4885            } else {
4886                Err(e)
4887            }
4888        }
4889    }
4890}
4891
4892/// Fetch `COUNT(*)` against the fully-qualified target. Returns 0 if
4893/// the query fails (e.g. after a catalog-invalidation quirk) so the
4894/// tool still returns a result — the caller cares that the copy
4895/// succeeded, not about bookkeeping fidelity.
4896fn count_rows(engine: &Engine, db: Option<&str>, table: &str) -> i64 {
4897    let sql = format!(
4898        "SELECT COUNT(*) AS cnt FROM {}",
4899        qualified_name(engine, db, table)
4900    );
4901    engine
4902        .execute_query_to_json(&sql)
4903        .ok()
4904        .and_then(|rows| {
4905            rows.first()
4906                .and_then(|r| r.get("cnt").and_then(serde_json::Value::as_i64))
4907        })
4908        .unwrap_or(0)
4909}
4910
4911/// Best-effort probe for public-schema tables visible under an alias.
4912/// Returns `Value::Null` on any error so the LLM sees "not available"
4913/// rather than a fabricated zero.
4914fn probe_table_count(engine: &Engine, alias: &str) -> Value {
4915    let escaped_alias = alias.replace('"', "\"\"");
4916    let sql = format!(
4917        "SELECT COUNT(*) AS cnt FROM \"{escaped_alias}\".pg_catalog.pg_tables WHERE schemaname = 'public'"
4918    );
4919    match engine.execute_query_to_json(&sql) {
4920        Ok(rows) => rows
4921            .first()
4922            .and_then(|r| r.get("cnt").and_then(serde_json::Value::as_i64))
4923            .map_or(Value::Null, |n| json!(n)),
4924        Err(_) => Value::Null,
4925    }
4926}
4927
4928/// Validate and convert `copy_query`'s `temp_attach` specs into
4929/// [`AttachRequest`]s. Runs entirely up front (no engine touching)
4930/// so a bad alias or path aborts cleanly before any ATTACH is issued.
4931fn prepare_temp_attachments(
4932    specs: &[AttachSpec],
4933    read_only: bool,
4934) -> Result<Vec<AttachRequest>, McpError> {
4935    let mut out = Vec::with_capacity(specs.len());
4936    for spec in specs {
4937        let writable = spec.writable.unwrap_or(false);
4938        if writable && read_only {
4939            return Err(McpError::new(
4940                ErrorCode::ReadOnlyViolation,
4941                format!(
4942                    "temp_attach for alias '{}' requested writable:true but the server is --read-only",
4943                    spec.alias
4944                ),
4945            ));
4946        }
4947        let on_missing = attach::OnMissing::parse(spec.on_missing.as_deref())?;
4948        if on_missing == attach::OnMissing::Create && !writable {
4949            return Err(McpError::new(
4950                ErrorCode::InvalidArgument,
4951                format!(
4952                    "temp_attach alias '{}' has on_missing='create' but writable is not true — \
4953                     an empty .hyper file that cannot be written to cannot be populated.",
4954                    spec.alias
4955                ),
4956            ));
4957        }
4958        let source = match spec.kind.as_str() {
4959            "local_file" => {
4960                let Some(raw) = spec.path.as_deref() else {
4961                    return Err(McpError::new(
4962                        ErrorCode::InvalidArgument,
4963                        format!("temp_attach alias '{}' requires a 'path'", spec.alias),
4964                    ));
4965                };
4966                let resolved = match on_missing {
4967                    attach::OnMissing::Error => attach::validate_local_path(raw)?,
4968                    attach::OnMissing::Create => attach::validate_local_path_for_create(raw)?,
4969                };
4970                AttachSource::LocalFile { path: resolved }
4971            }
4972            other => {
4973                return Err(McpError::new(
4974                    ErrorCode::InvalidArgument,
4975                    format!(
4976                        "Unsupported temp_attach kind '{other}' for alias '{}'. Only 'local_file' is supported today.",
4977                        spec.alias
4978                    ),
4979                ));
4980            }
4981        };
4982        attach::validate_alias(&spec.alias)?;
4983        out.push(AttachRequest {
4984            alias: spec.alias.clone(),
4985            source,
4986            writable,
4987            on_missing,
4988        });
4989    }
4990    Ok(out)
4991}
4992
4993/// Execute the chosen copy mode against the fully-qualified target
4994/// and return a JSON summary. Extracted from the `copy_query` handler
4995/// so the caller can run it inside the temp-attach cleanup wrapper
4996/// without re-duplicating the match arms.
4997fn perform_copy(
4998    engine: &Engine,
4999    mode: &str,
5000    target_db: Option<&str>,
5001    target_table: &str,
5002    sql_body: &str,
5003) -> Result<Value, McpError> {
5004    let qualified = qualified_name(engine, target_db, target_table);
5005    let exists = target_exists(engine, target_db, target_table)?;
5006    let timer = crate::stats::StatsTimer::start();
5007
5008    match mode {
5009        "create" => {
5010            if exists {
5011                return Err(McpError::new(
5012                    ErrorCode::InvalidArgument,
5013                    format!(
5014                        "Target '{target_table}' already exists. Use mode='append' to add rows or mode='replace' to drop and recreate."
5015                    ),
5016                ));
5017            }
5018            engine.execute_command(&format!("CREATE TABLE {qualified} AS {sql_body}"))?;
5019        }
5020        "append" => {
5021            if !exists {
5022                return Err(McpError::new(
5023                    ErrorCode::InvalidArgument,
5024                    format!(
5025                        "Target '{target_table}' does not exist. Use mode='create' to create it from the query or mode='replace' to drop and recreate."
5026                    ),
5027                ));
5028            }
5029            engine.execute_command(&format!("INSERT INTO {qualified} {sql_body}"))?;
5030        }
5031        "replace" => {
5032            // Hyper auto-commits DDL even inside transactions, so
5033            // DROP+CREATE isn't atomic across the statement boundary
5034            // (same caveat documented on `execute_in_transaction`).
5035            // We still issue them in order — the `IF EXISTS` guard
5036            // prevents an error when the target is absent, and the
5037            // follow-up `CREATE TABLE AS` either succeeds or leaves
5038            // the workspace with a dropped target, which is the
5039            // expected replace semantics.
5040            engine.execute_command(&format!("DROP TABLE IF EXISTS {qualified}"))?;
5041            engine.execute_command(&format!("CREATE TABLE {qualified} AS {sql_body}"))?;
5042        }
5043        other => {
5044            return Err(McpError::new(
5045                ErrorCode::InvalidArgument,
5046                format!("copy_query mode '{other}' is not supported"),
5047            ));
5048        }
5049    }
5050
5051    let elapsed_ms = timer.elapsed_ms();
5052    let row_count = count_rows(engine, target_db, target_table);
5053    Ok(json!({
5054        "target_table": target_table,
5055        "target_database": target_db.unwrap_or(LOCAL_ALIAS),
5056        "mode": mode,
5057        "row_count": row_count,
5058        "stats": { "operation": "copy_query", "elapsed_ms": elapsed_ms },
5059    }))
5060}
5061
5062#[cfg(test)]
5063mod validate_execute_batch_tests {
5064    use super::*;
5065
5066    fn s(v: &[&str]) -> Vec<String> {
5067        v.iter().map(|x| (*x).to_string()).collect()
5068    }
5069
5070    #[test]
5071    fn rejects_empty_array() {
5072        let err = validate_execute_batch(&[]).unwrap_err();
5073        assert_eq!(err.code, ErrorCode::InvalidArgument);
5074    }
5075
5076    #[test]
5077    fn rejects_whitespace_only_element() {
5078        let err = validate_execute_batch(&s(&["   "])).unwrap_err();
5079        assert_eq!(err.code, ErrorCode::InvalidArgument);
5080        assert!(err.message.contains("sql[0]"));
5081        let err = validate_execute_batch(&s(&["INSERT INTO t VALUES (1)", "/* */"])).unwrap_err();
5082        assert!(err.message.contains("sql[1]"));
5083    }
5084
5085    #[test]
5086    fn rejects_read_only_element() {
5087        let err =
5088            validate_execute_batch(&s(&["INSERT INTO t VALUES (1)", "SELECT 1"])).unwrap_err();
5089        assert_eq!(err.code, ErrorCode::SqlError);
5090        assert!(err.message.contains("sql[1]"));
5091    }
5092
5093    #[test]
5094    fn rejects_transaction_control_in_batch() {
5095        // The wrapper manages the transaction; a user-issued COMMIT
5096        // mid-batch would commit early and break atomicity.
5097        for sql in [
5098            "BEGIN",
5099            "COMMIT",
5100            "ROLLBACK",
5101            "SAVEPOINT sp1",
5102            "START TRANSACTION",
5103            "END",
5104            "RELEASE SAVEPOINT sp1",
5105        ] {
5106            let err = validate_execute_batch(&s(&["INSERT INTO t VALUES (1)", sql])).unwrap_err();
5107            assert_eq!(err.code, ErrorCode::InvalidArgument, "for `{sql}`");
5108            assert!(
5109                err.message.contains("transaction-control"),
5110                "for `{sql}`: {}",
5111                err.message
5112            );
5113        }
5114    }
5115
5116    #[test]
5117    fn rejects_transaction_control_singleton() {
5118        // Even a one-element BEGIN-only call is rejected — there's no
5119        // statement following it that could benefit, and the BEGIN
5120        // would leave the connection in an open-transaction state for
5121        // the next caller to inherit.
5122        let err = validate_execute_batch(&s(&["BEGIN"])).unwrap_err();
5123        assert_eq!(err.code, ErrorCode::InvalidArgument);
5124    }
5125
5126    #[test]
5127    fn rejects_ddl_dml_mix() {
5128        let err =
5129            validate_execute_batch(&s(&["CREATE TABLE x (i INT)", "INSERT INTO x VALUES (1)"]))
5130                .unwrap_err();
5131        assert_eq!(err.code, ErrorCode::InvalidArgument);
5132        assert!(err.message.contains("DDL"));
5133    }
5134
5135    #[test]
5136    fn rejects_multi_ddl() {
5137        let err = validate_execute_batch(&s(&["CREATE TABLE a (i INT)", "CREATE TABLE b (j INT)"]))
5138            .unwrap_err();
5139        assert_eq!(err.code, ErrorCode::InvalidArgument);
5140        assert!(err.message.contains("Multi-statement DDL"));
5141    }
5142
5143    #[test]
5144    fn allows_single_ddl() {
5145        validate_execute_batch(&s(&["CREATE TABLE a (i INT)"])).unwrap();
5146    }
5147
5148    #[test]
5149    fn allows_multi_dml() {
5150        validate_execute_batch(&s(&[
5151            "UPDATE settings SET value = 'x' WHERE key = 'k'",
5152            "INSERT INTO settings (key, value) SELECT 'k', 'x' WHERE NOT EXISTS (SELECT 1 FROM settings WHERE key = 'k')",
5153        ]))
5154        .unwrap();
5155    }
5156
5157    #[test]
5158    fn allows_other_kinds() {
5159        // Non-classified statements (e.g. SET, ATTACH) pass through.
5160        // Transaction-control keywords are NOT in this group — see
5161        // `rejects_transaction_control_*` tests.
5162        validate_execute_batch(&s(&["SET schema_search_path = 'mydb'"])).unwrap();
5163    }
5164
5165    #[test]
5166    fn allows_trailing_semicolon() {
5167        validate_execute_batch(&s(&["INSERT INTO t VALUES (1);"])).unwrap();
5168    }
5169}