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