1use crate::attach::{self, AttachRegistry, AttachRequest, AttachSource, LOCAL_ALIAS};
15use crate::chart::{render_chart, ChartFormat, ChartOptions, ChartType};
16use crate::engine::{is_read_only_sql, Engine};
17use crate::error::{ErrorCode, McpError};
18use crate::export::{export_to_file, ExportOptions};
19use crate::ingest::{
20 detect_file_format, ingest_csv, ingest_csv_file, ingest_csv_file_async, ingest_json,
21 ingest_json_file, ingest_json_file_async, InferredFileFormat, IngestOptions,
22};
23use crate::ingest_arrow::{
24 ingest_arrow_ipc_file, ingest_arrow_ipc_file_async, ingest_parquet_file,
25 ingest_parquet_file_async,
26};
27use crate::saved_queries::{build_store, SavedQuery, SavedQueryStore};
28use crate::subscriptions::{
29 uris_for_table_change, uris_for_workspace_change, SubscriptionRegistry,
30};
31use base64::Engine as _;
32use rmcp::handler::server::router::prompt::PromptRouter;
33use rmcp::handler::server::router::tool::ToolRouter;
34use rmcp::handler::server::wrapper::Parameters;
35use rmcp::model::{
36 AnnotateAble, CallToolResult, Content, GetPromptRequestParams, GetPromptResult, Implementation,
37 ListPromptsResult, ListResourceTemplatesResult, ListResourcesResult, PaginatedRequestParams,
38 PromptMessage, PromptMessageRole, RawResource, RawResourceTemplate, ReadResourceRequestParams,
39 ReadResourceResult, ResourceContents, ServerCapabilities, ServerInfo, SubscribeRequestParams,
40 UnsubscribeRequestParams,
41};
42use rmcp::service::RequestContext;
43use rmcp::{
44 prompt, prompt_handler, prompt_router, tool, tool_handler, tool_router, RoleServer,
45 ServerHandler,
46};
47use schemars::JsonSchema;
48use serde::Deserialize;
49use serde_json::{json, Value};
50use sqlformat::{FormatOptions, Indent, QueryParams as SqlQueryParams};
51use std::fmt::Write as _;
52use std::sync::{Arc, Mutex};
53
54#[expect(
55 unused_imports,
56 reason = "imported for use in doc comments that reference the type path"
57)]
58use rmcp::model::RawTextContent;
59
60const TABLE_SAMPLE_ROWS: u64 = 5;
64
65const TABLE_CSV_SAMPLE_ROWS: u64 = 20;
69
70#[derive(Debug, Deserialize, JsonSchema)]
101pub struct QueryDataParams {
102 pub data: String,
104 pub sql: String,
107 pub format: Option<String>,
110 pub table_name: Option<String>,
112 pub schema: Option<Value>,
117}
118
119#[derive(Debug, Deserialize, JsonSchema)]
121pub struct QueryFileParams {
122 pub path: String,
124 pub sql: String,
127 pub table_name: Option<String>,
129 pub schema: Option<Value>,
133 pub json_extract_path: Option<String>,
139}
140
141#[derive(Debug, Deserialize, JsonSchema)]
143pub struct LoadDataParams {
144 pub table: String,
146 pub data: String,
148 pub format: Option<String>,
150 pub mode: Option<String>,
153 pub schema: Option<Value>,
156}
157
158#[derive(Debug, Deserialize, JsonSchema)]
160pub struct LoadFileParams {
161 pub table: String,
163 pub path: String,
165 pub mode: Option<String>,
170 pub schema: Option<Value>,
177 pub json_extract_path: Option<String>,
183 pub merge_key: Option<MergeKey>,
188}
189
190#[derive(Debug, JsonSchema)]
200#[schemars(
201 title = "MergeKey",
202 description = "Either a single column name (string) or a list of column names (array of strings)",
203 untagged
204)]
205pub enum MergeKey {
206 Single(String),
207 Multi(Vec<String>),
208}
209
210impl<'de> Deserialize<'de> for MergeKey {
211 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
212 where
213 D: serde::Deserializer<'de>,
214 {
215 use serde::de::Error;
216 let v = serde_json::Value::deserialize(deserializer)?;
217 match v {
218 serde_json::Value::String(s) => Ok(Self::Single(s)),
219 serde_json::Value::Array(arr) => {
220 let mut names = Vec::with_capacity(arr.len());
221 for (i, item) in arr.into_iter().enumerate() {
222 match item {
223 serde_json::Value::String(s) => names.push(s),
224 other => {
225 return Err(D::Error::custom(format!(
226 "merge_key array element [{i}] must be a string \
227 (column name); got {other}"
228 )));
229 }
230 }
231 }
232 Ok(Self::Multi(names))
233 }
234 other => Err(D::Error::custom(format!(
235 "merge_key must be a column name (string) or list of column names \
236 (array of strings); got {other}"
237 ))),
238 }
239 }
240}
241
242impl MergeKey {
243 pub fn into_vec(self) -> Option<Vec<String>> {
247 let v = match self {
248 Self::Single(s) => vec![s],
249 Self::Multi(v) => v,
250 };
251 if v.is_empty() || v.iter().any(String::is_empty) {
252 None
253 } else {
254 Some(v)
255 }
256 }
257}
258
259#[derive(Debug, Deserialize, JsonSchema)]
263pub struct LoadFilesEntry {
264 pub table: String,
266 pub path: String,
268 pub mode: Option<String>,
271 pub schema: Option<Value>,
273 pub json_extract_path: Option<String>,
275 pub merge_key: Option<MergeKey>,
278}
279
280#[derive(Debug, Deserialize, JsonSchema)]
282pub struct LoadFilesParams {
283 pub files: Vec<LoadFilesEntry>,
287 pub concurrency: Option<u32>,
293}
294
295fn validate_merge_args(
305 mode: &str,
306 merge_key: Option<MergeKey>,
307) -> Result<Option<Vec<String>>, McpError> {
308 match (mode, merge_key) {
309 ("merge", None) => Err(McpError::new(
310 ErrorCode::InvalidArgument,
311 "mode=merge requires merge_key (a column name or list of column names)",
312 )),
313 ("merge", Some(mk)) => mk.into_vec().map(Some).ok_or_else(|| {
314 McpError::new(
315 ErrorCode::InvalidArgument,
316 "merge_key must be a non-empty list of non-empty column names",
317 )
318 }),
319 (_, Some(_)) => Err(McpError::new(
320 ErrorCode::InvalidArgument,
321 "merge_key is only valid with mode=merge",
322 )),
323 (_, None) => Ok(None),
324 }
325}
326
327#[derive(Debug, Deserialize, JsonSchema)]
333pub struct LoadIcebergParams {
334 pub table: String,
336 pub path: String,
339 pub mode: Option<String>,
341 pub metadata_filename: Option<String>,
344 pub version_as_of: Option<i64>,
346}
347
348#[derive(Debug, Deserialize, JsonSchema)]
350pub struct QueryParams {
351 pub sql: String,
353}
354
355#[derive(Debug, Deserialize, JsonSchema)]
357pub struct ExecuteParams {
358 pub sql: String,
360}
361
362#[derive(Debug, Deserialize, JsonSchema)]
364pub struct SampleParams {
365 pub table: String,
367 pub n: Option<u64>,
369}
370
371#[derive(Debug, Default, Deserialize, JsonSchema)]
375pub struct DescribeParams {
376 pub table: Option<String>,
379}
380
381#[derive(Debug, Deserialize, JsonSchema)]
383pub struct ChartParams {
384 pub sql: String,
386 pub chart_type: String,
388 pub x: Option<String>,
390 pub y: Option<String>,
392 pub series: Option<String>,
394 pub title: Option<String>,
396 pub format: Option<String>,
398 pub width: Option<u32>,
400 pub height: Option<u32>,
402 pub bins: Option<u32>,
404 pub x_as_category: Option<bool>,
410 pub x_range: Option<[f64; 2]>,
415 pub y_range: Option<[f64; 2]>,
418 pub color_map: Option<std::collections::HashMap<String, String>>,
423 pub label_points: Option<bool>,
428 pub output_path: Option<String>,
434 pub inline: Option<bool>,
440 pub overwrite: Option<bool>,
444}
445
446#[derive(Debug, Deserialize, JsonSchema)]
448pub struct WatchDirectoryParams {
449 pub path: String,
451 pub table: String,
453 #[serde(default)]
456 pub max_concurrent: Option<u32>,
457}
458
459#[derive(Debug, Deserialize, JsonSchema)]
461pub struct UnwatchDirectoryParams {
462 pub path: String,
464}
465
466#[derive(Debug, Deserialize, JsonSchema)]
476pub struct InspectFileParams {
477 pub path: String,
480 pub sample_rows: Option<u32>,
484 pub json_extract_path: Option<String>,
488}
489
490#[derive(Debug, Deserialize, JsonSchema)]
492pub struct ExportParams {
493 pub sql: Option<String>,
495 pub table: Option<String>,
497 pub path: String,
499 pub format: String,
504 pub overwrite: Option<bool>,
508 pub format_options: Option<Value>,
526}
527
528#[derive(Debug, Deserialize, JsonSchema)]
542pub struct SaveQueryParams {
543 pub name: String,
546 pub sql: String,
550 pub description: Option<String>,
552}
553
554#[derive(Debug, Deserialize, JsonSchema)]
556pub struct DeleteQueryParams {
557 pub name: String,
560}
561
562#[derive(Debug, Deserialize, JsonSchema, Clone)]
567pub struct AttachSpec {
568 pub alias: String,
572 pub kind: String,
576 pub path: Option<String>,
579 pub writable: Option<bool>,
583 pub on_missing: Option<String>,
589}
590
591#[derive(Debug, Deserialize, JsonSchema)]
595pub struct AttachDatabaseParams {
596 pub alias: String,
600 pub kind: String,
602 pub path: Option<String>,
606 pub writable: Option<bool>,
610 pub on_missing: Option<String>,
621}
622
623#[derive(Debug, Deserialize, JsonSchema)]
625pub struct DetachDatabaseParams {
626 pub alias: String,
628}
629
630#[derive(Debug, Deserialize, JsonSchema)]
638pub struct CopyQueryParams {
639 pub sql: String,
644 pub target_table: String,
647 pub mode: String,
655 pub target_database: Option<String>,
659 pub temp_attach: Option<Vec<AttachSpec>>,
663}
664
665#[derive(Debug, Deserialize, JsonSchema)]
673pub struct SetTableMetadataParams {
674 pub table: String,
678 pub source_url: Option<String>,
680 pub source_description: Option<String>,
683 pub purpose: Option<String>,
686 pub license: Option<String>,
688 pub notes: Option<String>,
690}
691
692#[derive(Debug, Deserialize, JsonSchema)]
696pub struct AnalyzeTableArgs {
697 pub table: String,
699}
700
701#[derive(Debug, Deserialize, JsonSchema)]
703pub struct CompareTablesArgs {
704 pub table_a: String,
706 pub table_b: String,
708}
709
710#[derive(Debug, Deserialize, JsonSchema)]
712pub struct DataQualityArgs {
713 pub table: String,
715}
716
717#[derive(Debug, Deserialize, JsonSchema)]
719pub struct SuggestQueriesArgs {
720 pub table: String,
722 pub goal: Option<String>,
724}
725
726pub struct HyperMcpServer {
735 engine: Arc<Mutex<Option<Engine>>>,
736 catalog_ready: Arc<Mutex<bool>>,
742 watchers: Arc<crate::watcher::WatcherRegistry>,
743 saved_queries: Arc<dyn SavedQueryStore>,
744 subscriptions: Arc<SubscriptionRegistry>,
745 attachments: Arc<AttachRegistry>,
751 workspace_path: Option<String>,
752 read_only: bool,
753 bare: bool,
758 #[expect(dead_code, reason = "constructed for rmcp 1.x macro-based dispatch")]
763 tool_router: ToolRouter<Self>,
764 #[expect(dead_code, reason = "constructed for rmcp 1.x macro-based dispatch")]
765 prompt_router: PromptRouter<Self>,
766}
767
768impl std::fmt::Debug for HyperMcpServer {
769 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
770 f.debug_struct("HyperMcpServer")
771 .field("workspace_path", &self.workspace_path)
772 .field("read_only", &self.read_only)
773 .field("bare", &self.bare)
774 .finish_non_exhaustive()
775 }
776}
777
778impl HyperMcpServer {
779 pub fn new(workspace_path: Option<String>, read_only: bool, bare: bool) -> Self {
800 let saved_queries: Arc<dyn SavedQueryStore> = if bare {
803 build_store(None)
804 } else {
805 build_store(workspace_path.as_deref())
806 };
807 Self {
808 engine: Arc::new(Mutex::new(None)),
809 catalog_ready: Arc::new(Mutex::new(false)),
810 watchers: Arc::new(crate::watcher::WatcherRegistry::new()),
811 saved_queries,
812 subscriptions: Arc::new(SubscriptionRegistry::new()),
813 attachments: Arc::new(AttachRegistry::with_catalog_policy(!bare)),
818 workspace_path,
819 read_only,
820 bare,
821 tool_router: Self::tool_router(),
822 prompt_router: Self::prompt_router(),
823 }
824 }
825
826 #[must_use]
830 pub fn is_bare(&self) -> bool {
831 self.bare
832 }
833
834 #[must_use]
838 pub fn subscriptions_handle(&self) -> Arc<SubscriptionRegistry> {
839 Arc::clone(&self.subscriptions)
840 }
841
842 pub(crate) fn notify_table_changed(&self, table: &str) {
849 for uri in uris_for_table_change(table) {
850 self.subscriptions.notify_updated(&uri);
851 }
852 }
853
854 pub(crate) fn notify_workspace_changed(&self) {
859 for uri in uris_for_workspace_change() {
860 self.subscriptions.notify_updated(uri);
861 }
862 }
863
864 pub(crate) fn notify_resource_list_changed(&self) {
869 self.subscriptions.notify_list_changed();
870 }
871
872 #[must_use]
875 pub fn engine_handle(&self) -> Arc<Mutex<Option<Engine>>> {
876 Arc::clone(&self.engine)
877 }
878
879 #[must_use]
881 pub fn watchers_handle(&self) -> Arc<crate::watcher::WatcherRegistry> {
882 Arc::clone(&self.watchers)
883 }
884
885 #[must_use]
888 pub fn attachments_handle(&self) -> Arc<AttachRegistry> {
889 Arc::clone(&self.attachments)
890 }
891
892 #[must_use]
894 pub fn is_read_only(&self) -> bool {
895 self.read_only
896 }
897
898 fn check_writable(&self, operation: &str) -> Result<(), McpError> {
901 if self.read_only {
902 Err(McpError::new(
903 ErrorCode::ReadOnlyViolation,
904 format!("Operation '{operation}' is not permitted in read-only mode"),
905 ))
906 } else {
907 Ok(())
908 }
909 }
910
911 fn ensure_engine(&self) -> Result<std::sync::MutexGuard<'_, Option<Engine>>, McpError> {
920 let mut guard = self
921 .engine
922 .lock()
923 .map_err(|_| McpError::new(ErrorCode::InternalError, "Lock poisoned"))?;
924 if guard.is_none() {
925 tracing::info!(
926 workspace = self.workspace_path.as_deref().unwrap_or("<ephemeral>"),
927 bare = self.bare,
928 "initializing hyper engine"
929 );
930 let engine = Engine::new(self.workspace_path.clone())?;
931 tracing::info!(
932 workspace_path = %engine.workspace_path().display(),
933 log_dir = %engine.log_dir().display(),
934 "engine ready"
935 );
936 if let Err(e) = self.attachments.replay_all(&engine) {
945 tracing::warn!(err = %e.message, "failed to replay attachments on new engine");
946 }
947 *guard = Some(engine);
948 if let Ok(mut ready) = self.catalog_ready.lock() {
952 *ready = false;
953 }
954 }
955 Ok(guard)
956 }
957
958 fn ensure_catalog_ready(&self, engine: &Engine) {
967 if self.bare || self.read_only {
968 return;
969 }
970 let Ok(mut ready) = self.catalog_ready.lock() else {
971 return;
972 };
973 if *ready {
974 return;
975 }
976 if let Err(e) = crate::table_catalog::ensure_exists(engine) {
977 tracing::warn!(err = %e.message, "failed to ensure _table_catalog exists");
978 }
979 if let Err(e) = crate::table_catalog::reconcile(engine) {
980 tracing::warn!(err = %e.message, "failed to reconcile _table_catalog on startup");
981 }
982 *ready = true;
983 }
984
985 fn after_ingest_catalog_update(
989 &self,
990 engine: &Engine,
991 table_name: &str,
992 load_tool: &'static str,
993 load_params: Option<&str>,
994 row_count: Option<i64>,
995 ) {
996 if self.bare {
997 return;
998 }
999 if let Err(e) = crate::table_catalog::upsert_stub(
1000 engine,
1001 table_name,
1002 load_tool,
1003 load_params,
1004 row_count,
1005 true,
1006 ) {
1007 tracing::warn!(
1008 table = %table_name,
1009 err = %e.message,
1010 "failed to update _table_catalog after ingest"
1011 );
1012 }
1013 }
1014
1015 fn after_execute_catalog_update(&self, engine: &Engine) {
1018 if self.bare {
1019 return;
1020 }
1021 if let Err(e) = crate::table_catalog::reconcile(engine) {
1022 tracing::warn!(
1023 err = %e.message,
1024 "failed to reconcile _table_catalog after execute"
1025 );
1026 }
1027 }
1028
1029 fn with_engine<F, R>(&self, f: F) -> Result<R, McpError>
1038 where
1039 F: FnOnce(&Engine) -> Result<R, McpError>,
1040 {
1041 let mut guard = self.ensure_engine()?;
1042 let engine = guard.as_ref().expect("ensure_engine guarantees Some");
1043 self.ensure_catalog_ready(engine);
1048 let result = f(engine);
1049 if let Err(e) = &result {
1050 tracing::debug!(code = ?e.code, message = %e.message, "tool call returned error");
1051 if e.code == ErrorCode::ConnectionLost {
1052 tracing::warn!(
1053 "connection to hyperd lost or desynchronized ({}); \
1058 dropping engine so next call reconnects",
1059 e.message
1060 );
1061 *guard = None;
1062 if let Ok(mut ready) = self.catalog_ready.lock() {
1065 *ready = false;
1066 }
1067 }
1068 }
1069 result
1070 }
1071
1072 fn with_saved_query_store<F, R>(&self, f: F) -> Result<R, McpError>
1082 where
1083 F: FnOnce(Option<&Engine>) -> Result<R, McpError>,
1084 {
1085 if self.workspace_path.is_some() {
1086 self.with_engine(|engine| f(Some(engine)))
1087 } else {
1088 f(None)
1089 }
1090 }
1091
1092 #[expect(
1093 clippy::unnecessary_wraps,
1094 reason = "signature retained for API symmetry / future fallibility; returning Result/Option keeps callers from breaking when the function later grows failure cases"
1095 )]
1096 fn ok_content(val: Value) -> Result<CallToolResult, rmcp::ErrorData> {
1101 let text = serde_json::to_string_pretty(&val).unwrap_or_default();
1102 let mut result = CallToolResult::structured(val);
1103 result.content = vec![Content::text(text)];
1107 Ok(result)
1108 }
1109
1110 fn fmt_sql(sql: &str) -> String {
1113 let opts = FormatOptions {
1114 indent: Indent::Spaces(2),
1115 uppercase: Some(true),
1116 lines_between_queries: 1,
1117 ..FormatOptions::default()
1118 };
1119 let formatted = sqlformat::format(sql, &SqlQueryParams::None, &opts);
1120 if formatted.trim().is_empty() {
1121 sql.to_owned()
1122 } else {
1123 formatted
1124 }
1125 }
1126
1127 #[expect(
1128 clippy::unnecessary_wraps,
1129 reason = "signature retained for API symmetry / future fallibility; returning Result/Option keeps callers from breaking when the function later grows failure cases"
1130 )]
1131 #[expect(
1132 clippy::needless_pass_by_value,
1133 reason = "call-site ergonomics: function consumes logically-owned parameters, refactoring signatures is not worth per-site churn"
1134 )]
1135 fn err_content(e: McpError) -> Result<CallToolResult, rmcp::ErrorData> {
1140 let err_val = serde_json::to_value(&e).unwrap_or(Value::String(e.to_string()));
1141 let body = json!({"error": err_val});
1142 let text = serde_json::to_string_pretty(&body).unwrap_or_default();
1143 let mut result = CallToolResult::structured_error(body);
1144 result.content = vec![Content::text(text)];
1145 Ok(result)
1146 }
1147}
1148
1149#[tool_router]
1150impl HyperMcpServer {
1151 #[tool(
1153 description = "Ingest inline data (JSON or CSV) and run a SQL query in one call. Creates a temp table, queries, discards."
1154 )]
1155 fn query_data(
1156 &self,
1157 Parameters(params): Parameters<QueryDataParams>,
1158 ) -> Result<CallToolResult, rmcp::ErrorData> {
1159 let result = self.with_engine(|engine| {
1160 let tname = params.table_name.unwrap_or_else(|| "data".into());
1161 let temp_table = format!("_tmp_{}_{}", tname, rand_suffix());
1162 let fmt = params.format.unwrap_or_else(|| detect_format(¶ms.data));
1163 let schema_override = crate::schema::normalize_schema_param(params.schema.as_ref())?;
1164 let opts = IngestOptions {
1165 table: temp_table.clone(),
1166 mode: "replace".into(),
1167 schema_override,
1168 merge_key: None,
1169 };
1170
1171 let ingest_result = match fmt.as_str() {
1172 "csv" => ingest_csv(engine, ¶ms.data, &opts),
1173 _ => ingest_json(engine, ¶ms.data, &opts),
1174 }?;
1175
1176 let query_sql = params.sql.replace(&tname, &temp_table);
1177 let rows = engine.execute_query_to_json(&query_sql)?;
1178 let _ = engine.execute_command(&format!("DROP TABLE IF EXISTS \"{temp_table}\""));
1179
1180 Ok(json!({
1181 "sql": Self::fmt_sql(¶ms.sql),
1182 "result": rows,
1183 "stats": ingest_result.stats.to_json(),
1184 }))
1185 });
1186
1187 match result {
1188 Ok(val) => Self::ok_content(val),
1189 Err(e) => Self::err_content(e),
1190 }
1191 }
1192
1193 #[tool(
1195 description = "Ingest a file (CSV, JSON, JSONL / NDJSON, Parquet, Arrow IPC) and run a SQL query in one call. JSON files may be either a top-level array of objects or newline-delimited JSON (JSONL); the format is auto-detected from the first byte. Use `json_extract_path` to extract a nested data array from a JSON wrapper file (e.g., MCP tool responses saved to disk). The path is dot-separated; numeric segments index into arrays; string values are automatically parsed as JSON."
1196 )]
1197 fn query_file(
1198 &self,
1199 Parameters(params): Parameters<QueryFileParams>,
1200 ) -> Result<CallToolResult, rmcp::ErrorData> {
1201 let result = self.with_engine(|engine| {
1202 crate::attach::validate_input_path(¶ms.path, "data file")?;
1203 let stem = std::path::Path::new(¶ms.path)
1204 .file_stem()
1205 .and_then(|s| s.to_str())
1206 .unwrap_or("file")
1207 .to_string();
1208 let tname = params.table_name.unwrap_or_else(|| stem.clone());
1209 let temp_table = format!("_tmp_{}_{}", tname, rand_suffix());
1210 let schema_override = crate::schema::normalize_schema_param(params.schema.as_ref())?;
1211 let opts = IngestOptions {
1212 table: temp_table.clone(),
1213 mode: "replace".into(),
1214 schema_override,
1215 merge_key: None,
1216 };
1217
1218 let ingest_result = if let Some(ref json_path) = params.json_extract_path {
1219 let raw = std::fs::read_to_string(¶ms.path).map_err(|e| {
1220 McpError::new(
1221 ErrorCode::FileNotFound,
1222 format!("Cannot read file '{}': {e}", params.path),
1223 )
1224 })?;
1225 let extracted = crate::ingest::extract_json_path(&raw, json_path)?;
1226 let array_text = crate::ingest::normalize_json_or_jsonl(&extracted)?;
1227 let mut result = ingest_json(engine, &array_text, &opts)?;
1228 result.stats.operation = "query_file".into();
1229 result.stats.bytes_read = std::fs::metadata(¶ms.path).map_or(0, |m| m.len());
1230 result.stats.file_format = Some("json".into());
1231 result
1232 } else {
1233 match detect_file_format(std::path::Path::new(¶ms.path)) {
1234 InferredFileFormat::Parquet => ingest_parquet_file(engine, ¶ms.path, &opts),
1235 InferredFileFormat::ArrowIpc => {
1236 ingest_arrow_ipc_file(engine, ¶ms.path, &opts)
1237 }
1238 InferredFileFormat::Json => ingest_json_file(engine, ¶ms.path, &opts),
1239 InferredFileFormat::Csv => ingest_csv_file(engine, ¶ms.path, &opts),
1240 }?
1241 };
1242
1243 let query_sql = params.sql.replace(&tname, &temp_table);
1244 let rows = engine.execute_query_to_json(&query_sql)?;
1245 let _ = engine.execute_command(&format!("DROP TABLE IF EXISTS \"{temp_table}\""));
1246
1247 Ok(json!({
1248 "sql": Self::fmt_sql(¶ms.sql),
1249 "result": rows,
1250 "stats": ingest_result.stats.to_json(),
1251 }))
1252 });
1253
1254 match result {
1255 Ok(val) => Self::ok_content(val),
1256 Err(e) => Self::err_content(e),
1257 }
1258 }
1259
1260 #[tool(
1262 description = "Load inline data (JSON or CSV) into a named workspace table. Supports partial `schema` overrides keyed by column name — only list the columns you want to correct, the rest keep their inferred type. On SchemaMismatch / numeric overflow, follow the error's suggestion (typically widen an INT column to BIGINT or NUMERIC(38,0))."
1263 )]
1264 fn load_data(
1265 &self,
1266 Parameters(params): Parameters<LoadDataParams>,
1267 ) -> Result<CallToolResult, rmcp::ErrorData> {
1268 if let Err(e) = self.check_writable("load_data") {
1269 return Self::err_content(e);
1270 }
1271 let table_name = params.table.clone();
1272 let mode = params.mode.clone().unwrap_or_else(|| "replace".into());
1277 let result = self.with_engine(|engine| {
1278 let fmt = params.format.unwrap_or_else(|| detect_format(¶ms.data));
1279 let schema_override = crate::schema::normalize_schema_param(params.schema.as_ref())?;
1280 let opts = IngestOptions {
1281 table: params.table.clone(),
1282 mode: mode.clone(),
1283 schema_override,
1284 merge_key: None,
1285 };
1286
1287 let ingest_result = match fmt.as_str() {
1288 "csv" => ingest_csv(engine, ¶ms.data, &opts),
1289 _ => ingest_json(engine, ¶ms.data, &opts),
1290 }?;
1291
1292 let schema_json: Vec<Value> = ingest_result
1293 .schema
1294 .iter()
1295 .map(|c| {
1296 json!({
1297 "name": c.name,
1298 "type": c.hyper_type,
1299 "nullable": c.nullable,
1300 })
1301 })
1302 .collect();
1303
1304 let load_params = serde_json::to_string(&json!({
1309 "mode": mode,
1310 "format": fmt,
1311 }))
1312 .ok();
1313 self.after_ingest_catalog_update(
1314 engine,
1315 ¶ms.table,
1316 "load_data",
1317 load_params.as_deref(),
1318 i64::try_from(ingest_result.rows).ok(),
1319 );
1320
1321 Ok(json!({
1322 "rows": ingest_result.rows,
1323 "schema": schema_json,
1324 "stats": ingest_result.stats.to_json(),
1325 }))
1326 });
1327
1328 match result {
1329 Ok(val) => {
1330 self.notify_table_changed(&table_name);
1331 if mode == "replace" {
1332 self.notify_resource_list_changed();
1336 }
1337 Self::ok_content(val)
1338 }
1339 Err(e) => Self::err_content(e),
1340 }
1341 }
1342
1343 #[tool(
1345 description = "Load a CSV / JSON / JSONL / NDJSON / Parquet / Arrow IPC file into a named workspace table. Format is auto-detected from extension (or content for JSON vs CSV).\n\nWhen choosing a format for *new* data going into Hyper, prefer in this order:\n 1. **Parquet** (fastest, server-side): hyperd reads the file directly via `external()`. Types, NUMERIC precision, DATE / TIMESTAMP, and Snappy/ZSTD compression all preserved. This is the recommended format for large imports.\n 2. **CSV**: server-side `COPY FROM` — also fast, but types are inferred from a header + full-file numeric widening pass (CSV has no embedded type info), and empty unquoted cells load as SQL NULL per PostgreSQL CSV default.\n 3. **Arrow IPC** (.arrow / .ipc / .feather, File or Stream format, auto-detected): read in Rust and streamed into hyperd via the binary COPY protocol with zero value-level decoding. Fast but not quite as fast as Parquet, and schema overrides are rejected (the Arrow schema is authoritative).\n 4. **JSON / JSONL / NDJSON**: parsed in Rust (hyperd has no native JSON reader), with per-row insertion. Use for small / irregular data; large JSON should be converted to Parquet first.\n\nFor Apache Iceberg tables use `load_iceberg` instead — it takes a directory path rather than a single file.\n\nSupports partial `schema` overrides keyed by column name (`{\"col\":\"BIGINT\"}`) — only list columns you want to correct; unlisted columns keep their inferred type. Overrides are supported for Parquet, CSV, and JSON; rejected for Arrow IPC. Call `inspect_file` first when unsure about types or to debug a prior failure; the inspector reports per-column min/max/null_count using the exact same inference logic. Use `json_extract_path` to extract a nested data array from a JSON wrapper file — dot-separated path, numeric segments index into arrays, string values are parsed as JSON.\n\n**Mode**: `replace` (default — drops + recreates the table), `append` (adds rows to an existing table), or `merge` (upserts rows by `merge_key`). In merge mode, set `merge_key` to a column name (`\"job_id\"`) or list of names (`[\"cell\",\"job_id\"]`); rows with a matching key are replaced, rows with no match are inserted. New columns in the incoming file are auto-added via `ALTER TABLE ADD COLUMN`. Type changes on existing columns are rejected — use `replace` for breaking schema changes."
1346 )]
1347 fn load_file(
1348 &self,
1349 Parameters(params): Parameters<LoadFileParams>,
1350 ) -> Result<CallToolResult, rmcp::ErrorData> {
1351 if let Err(e) = self.check_writable("load_file") {
1352 return Self::err_content(e);
1353 }
1354 let table_name = params.table.clone();
1355 let mode = params.mode.clone().unwrap_or_else(|| "replace".into());
1356 let merge_key_vec = match validate_merge_args(&mode, params.merge_key) {
1360 Ok(v) => v,
1361 Err(e) => return Self::err_content(e),
1362 };
1363 let result = self.with_engine(|engine| {
1368 crate::attach::validate_input_path(¶ms.path, "data file")?;
1369 let schema_override = crate::schema::normalize_schema_param(params.schema.as_ref())?;
1370 let opts = IngestOptions {
1371 table: params.table.clone(),
1372 mode: mode.clone(),
1373 schema_override,
1374 merge_key: merge_key_vec.clone(),
1375 };
1376
1377 let ingest_result = if let Some(ref json_path) = params.json_extract_path {
1378 let raw = std::fs::read_to_string(¶ms.path).map_err(|e| {
1379 McpError::new(
1380 ErrorCode::FileNotFound,
1381 format!("Cannot read file '{}': {e}", params.path),
1382 )
1383 })?;
1384 let extracted = crate::ingest::extract_json_path(&raw, json_path)?;
1385 let array_text = crate::ingest::normalize_json_or_jsonl(&extracted)?;
1386 let mut result = ingest_json(engine, &array_text, &opts)?;
1387 result.stats.operation = "load_file".into();
1388 result.stats.bytes_read = std::fs::metadata(¶ms.path).map_or(0, |m| m.len());
1389 result.stats.file_format = Some("json".into());
1390 result
1391 } else {
1392 match detect_file_format(std::path::Path::new(¶ms.path)) {
1393 InferredFileFormat::Parquet => ingest_parquet_file(engine, ¶ms.path, &opts),
1394 InferredFileFormat::ArrowIpc => {
1395 ingest_arrow_ipc_file(engine, ¶ms.path, &opts)
1396 }
1397 InferredFileFormat::Json => ingest_json_file(engine, ¶ms.path, &opts),
1398 InferredFileFormat::Csv => ingest_csv_file(engine, ¶ms.path, &opts),
1399 }?
1400 };
1401
1402 let schema_changed = ingest_result.stats.schema_changed;
1406
1407 let schema_json: Vec<Value> = ingest_result
1408 .schema
1409 .iter()
1410 .map(|c| {
1411 json!({
1412 "name": c.name,
1413 "type": c.hyper_type,
1414 "nullable": c.nullable,
1415 })
1416 })
1417 .collect();
1418
1419 let load_params = serde_json::to_string(&json!({
1423 "source_path": params.path,
1424 "mode": mode,
1425 "schema": params.schema,
1426 "json_extract_path": params.json_extract_path,
1427 "merge_key": merge_key_vec,
1428 }))
1429 .ok();
1430 self.after_ingest_catalog_update(
1431 engine,
1432 ¶ms.table,
1433 "load_file",
1434 load_params.as_deref(),
1435 i64::try_from(ingest_result.rows).ok(),
1436 );
1437
1438 Ok((
1439 json!({
1440 "rows": ingest_result.rows,
1441 "schema": schema_json,
1442 "stats": ingest_result.stats.to_json(),
1443 }),
1444 schema_changed,
1445 ))
1446 });
1447
1448 match result {
1449 Ok((val, schema_changed)) => {
1450 self.notify_table_changed(&table_name);
1451 if mode == "replace" || schema_changed {
1459 self.notify_resource_list_changed();
1460 }
1461 Self::ok_content(val)
1462 }
1463 Err(e) => Self::err_content(e),
1464 }
1465 }
1466
1467 #[tool(
1471 description = "Ingest multiple files in parallel. Each entry is equivalent to a standalone `load_file` call (same formats and same format-selection guidance: prefer Parquet > CSV > Arrow IPC > JSON for large imports). The batch runs across a pool of async connections sized by `concurrency` (default `min(files.len(), 8)`), so independent files finish roughly in max-time rather than sum-time. Per-file errors are captured in the response and do not abort the rest of the batch; the top-level call still returns Ok. For Apache Iceberg tables, call `load_iceberg` per table instead — this tool only handles single-file formats. **Note: `mode = \"merge\"` is not supported here — use `load_file` once per file when you need merge/upsert semantics.**"
1472 )]
1473 fn load_files(
1474 &self,
1475 Parameters(params): Parameters<LoadFilesParams>,
1476 ) -> Result<CallToolResult, rmcp::ErrorData> {
1477 use hyperdb_api::pool::{create_pool, PoolConfig};
1478 use hyperdb_api::CreateMode;
1479
1480 if let Err(e) = self.check_writable("load_files") {
1481 return Self::err_content(e);
1482 }
1483 if params.files.is_empty() {
1484 return Self::err_content(McpError::new(
1485 ErrorCode::EmptyData,
1486 "load_files: `files` must not be empty",
1487 ));
1488 }
1489
1490 for (idx, entry) in params.files.iter().enumerate() {
1497 if let Err(mut e) = crate::attach::validate_input_path(&entry.path, "data file") {
1498 e.message = format!("entry {idx} (table '{}'): {}", entry.table, e.message);
1499 return Self::err_content(e);
1500 }
1501 let mode = entry.mode.as_deref().unwrap_or("replace");
1502 if mode == "merge" || entry.merge_key.is_some() {
1503 return Self::err_content(McpError::new(
1504 ErrorCode::InvalidArgument,
1505 format!(
1506 "load_files does not support mode=merge yet (entry {idx}, table \
1507 '{}'). Call load_file once per file when you need merge semantics.",
1508 entry.table
1509 ),
1510 ));
1511 }
1512 }
1513
1514 let (endpoint, workspace) = match self.with_engine(|engine| {
1518 let endpoint = engine.hyperd_endpoint()?;
1519 let workspace = engine.workspace_path().to_string_lossy().to_string();
1520 Ok((endpoint, workspace))
1521 }) {
1522 Ok(v) => v,
1523 Err(e) => return Self::err_content(e),
1524 };
1525
1526 let file_count = params.files.len();
1529 let concurrency = params
1530 .concurrency
1531 .map_or(8, |n| n as usize)
1532 .min(file_count)
1533 .clamp(1, 16);
1534
1535 let pool = match create_pool(
1536 PoolConfig::new(endpoint, workspace)
1537 .create_mode(CreateMode::DoNotCreate)
1538 .max_size(concurrency),
1539 ) {
1540 Ok(p) => Arc::new(p),
1541 Err(e) => {
1542 return Self::err_content(McpError::new(
1543 ErrorCode::InternalError,
1544 format!("Failed to build connection pool for load_files: {e}"),
1545 ))
1546 }
1547 };
1548
1549 let Ok(rt) = tokio::runtime::Handle::try_current() else {
1552 return Self::err_content(McpError::new(
1553 ErrorCode::InternalError,
1554 "load_files must run inside a tokio runtime",
1555 ));
1556 };
1557
1558 #[derive(Default)]
1561 struct EntryOutcome {
1562 table: String,
1563 ok: Option<(u64, Vec<Value>, Value)>,
1564 err: Option<(ErrorCode, String)>,
1565 replace_mode: bool,
1566 }
1567
1568 let outcomes: Vec<EntryOutcome> = tokio::task::block_in_place(|| {
1569 rt.block_on(async {
1570 let mut set = tokio::task::JoinSet::new();
1571 for (idx, entry) in params.files.into_iter().enumerate() {
1572 let pool = Arc::clone(&pool);
1573 set.spawn(async move {
1574 let mode = entry.mode.clone().unwrap_or_else(|| "replace".into());
1575 let replace_mode = mode == "replace";
1576 let mut out = EntryOutcome {
1577 table: entry.table.clone(),
1578 replace_mode,
1579 ..Default::default()
1580 };
1581
1582 let schema_override =
1587 match crate::schema::normalize_schema_param(entry.schema.as_ref()) {
1588 Ok(v) => v,
1589 Err(e) => {
1590 out.err = Some((e.code, e.message));
1591 return (idx, out);
1592 }
1593 };
1594 let opts = IngestOptions {
1595 table: entry.table.clone(),
1596 mode: mode.clone(),
1597 schema_override,
1598 merge_key: None,
1599 };
1600
1601 let conn = match pool.get().await {
1604 Ok(c) => c,
1605 Err(e) => {
1606 out.err = Some((
1607 ErrorCode::InternalError,
1608 format!("Failed to check out connection: {e}"),
1609 ));
1610 return (idx, out);
1611 }
1612 };
1613
1614 let ingest_res = if let Some(ref json_path) = entry.json_extract_path {
1619 let raw = match std::fs::read_to_string(&entry.path) {
1620 Ok(s) => s,
1621 Err(e) => {
1622 out.err = Some((
1623 ErrorCode::FileNotFound,
1624 format!("Cannot read file '{}': {e}", entry.path),
1625 ));
1626 return (idx, out);
1627 }
1628 };
1629 let extracted = match crate::ingest::extract_json_path(&raw, json_path)
1630 {
1631 Ok(v) => v,
1632 Err(e) => {
1633 out.err = Some((e.code, e.message));
1634 return (idx, out);
1635 }
1636 };
1637 let array_text =
1638 match crate::ingest::normalize_json_or_jsonl(&extracted) {
1639 Ok(v) => v,
1640 Err(e) => {
1641 out.err = Some((e.code, e.message));
1642 return (idx, out);
1643 }
1644 };
1645 crate::ingest::ingest_json_async(&conn, &array_text, &opts)
1646 .await
1647 .map(|mut r| {
1648 r.stats.operation = "load_file".into();
1649 r.stats.bytes_read =
1650 std::fs::metadata(&entry.path).map_or(0, |m| m.len());
1651 r.stats.file_format = Some("json".into());
1652 r
1653 })
1654 } else {
1655 match detect_file_format(std::path::Path::new(&entry.path)) {
1656 InferredFileFormat::Parquet => {
1657 ingest_parquet_file_async(&conn, &entry.path, &opts).await
1658 }
1659 InferredFileFormat::ArrowIpc => {
1660 ingest_arrow_ipc_file_async(&conn, &entry.path, &opts).await
1661 }
1662 InferredFileFormat::Json => {
1663 ingest_json_file_async(&conn, &entry.path, &opts).await
1664 }
1665 InferredFileFormat::Csv => {
1666 ingest_csv_file_async(&conn, &entry.path, &opts).await
1667 }
1668 }
1669 };
1670
1671 match ingest_res {
1672 Ok(r) => {
1673 let schema_json: Vec<Value> = r
1674 .schema
1675 .iter()
1676 .map(|c| {
1677 json!({
1678 "name": c.name,
1679 "type": c.hyper_type,
1680 "nullable": c.nullable,
1681 })
1682 })
1683 .collect();
1684 out.ok = Some((r.rows, schema_json, r.stats.to_json()));
1685 }
1686 Err(e) => {
1687 out.err = Some((e.code, e.message));
1688 }
1689 }
1690
1691 (idx, out)
1692 });
1693 }
1694
1695 let mut collected: Vec<Option<EntryOutcome>> =
1698 (0..file_count).map(|_| None).collect();
1699 while let Some(joined) = set.join_next().await {
1700 match joined {
1701 Ok((idx, outcome)) => collected[idx] = Some(outcome),
1702 Err(e) => {
1703 tracing::warn!("load_files task join error: {e}");
1706 }
1707 }
1708 }
1709 collected.into_iter().flatten().collect()
1710 })
1711 });
1712
1713 let mut any_replace_succeeded = false;
1717 let mut tables_to_notify: Vec<String> = Vec::new();
1718 let results_json: Vec<Value> = outcomes
1719 .iter()
1720 .map(|o| match (&o.ok, &o.err) {
1721 (Some((rows, schema, stats)), _) => {
1722 tables_to_notify.push(o.table.clone());
1723 if o.replace_mode {
1724 any_replace_succeeded = true;
1725 }
1726 json!({
1727 "table": o.table,
1728 "rows": rows,
1729 "schema": schema,
1730 "stats": stats,
1731 })
1732 }
1733 (None, Some((code, msg))) => json!({
1734 "table": o.table,
1735 "error": {
1736 "code": format!("{:?}", code),
1737 "message": msg,
1738 }
1739 }),
1740 (None, None) => json!({
1743 "table": o.table,
1744 "error": {
1745 "code": "InternalError",
1746 "message": "load_files task produced no outcome",
1747 }
1748 }),
1749 })
1750 .collect();
1751
1752 if let Err(e) = self.with_engine(|engine| {
1755 for o in &outcomes {
1756 if let Some((rows, _, _)) = &o.ok {
1757 self.after_ingest_catalog_update(
1758 engine,
1759 &o.table,
1760 "load_file",
1761 None,
1762 i64::try_from(*rows).ok(),
1763 );
1764 }
1765 }
1766 Ok(())
1767 }) {
1768 tracing::warn!("load_files: catalog update batch failed: {}", e.message);
1769 }
1770
1771 for t in &tables_to_notify {
1772 self.notify_table_changed(t);
1773 }
1774 if any_replace_succeeded {
1775 self.notify_resource_list_changed();
1776 }
1777
1778 let success_count = outcomes.iter().filter(|o| o.ok.is_some()).count();
1779 let failure_count = outcomes.len() - success_count;
1780
1781 Self::ok_content(json!({
1782 "results": results_json,
1783 "summary": {
1784 "total": outcomes.len(),
1785 "succeeded": success_count,
1786 "failed": failure_count,
1787 "concurrency": concurrency,
1788 }
1789 }))
1790 }
1791
1792 #[tool(
1795 description = "Ingest an Apache Iceberg table into a workspace table using hyperd's native Iceberg reader. `path` must be an absolute path to the Iceberg table *root directory* (the one containing the `metadata/` and `data/` subdirs). Hyperd resolves the latest snapshot by default; pass `metadata_filename` (e.g. `v2.metadata.json`) or `version_as_of` to pin a specific snapshot or version. Mode is `replace` (default) or `append`. Single SQL statement under the hood — no Rust-side Arrow decode, no per-row INSERTs."
1796 )]
1797 fn load_iceberg(
1798 &self,
1799 Parameters(params): Parameters<LoadIcebergParams>,
1800 ) -> Result<CallToolResult, rmcp::ErrorData> {
1801 if let Err(e) = self.check_writable("load_iceberg") {
1802 return Self::err_content(e);
1803 }
1804 let table_name = params.table.clone();
1805 let mode = params.mode.clone().unwrap_or_else(|| "replace".into());
1806 let opts = crate::lakehouse::IcebergIngestOptions {
1807 table: params.table.clone(),
1808 mode: mode.clone(),
1809 metadata_filename: params.metadata_filename.clone(),
1810 version_as_of: params.version_as_of,
1811 };
1812
1813 let result = self.with_engine(|engine| {
1814 crate::attach::validate_input_path(¶ms.path, "iceberg table")?;
1816 let ingest_result =
1817 crate::lakehouse::ingest_iceberg_table(engine, ¶ms.path, &opts)?;
1818
1819 let schema_json: Vec<Value> = ingest_result
1820 .schema
1821 .iter()
1822 .map(|c| {
1823 json!({
1824 "name": c.name,
1825 "type": c.hyper_type,
1826 "nullable": c.nullable,
1827 })
1828 })
1829 .collect();
1830
1831 let load_params = serde_json::to_string(&json!({
1832 "source_path": params.path,
1833 "mode": mode,
1834 "format": "iceberg",
1835 "metadata_filename": params.metadata_filename,
1836 "version_as_of": params.version_as_of,
1837 }))
1838 .ok();
1839 self.after_ingest_catalog_update(
1840 engine,
1841 ¶ms.table,
1842 "load_iceberg",
1843 load_params.as_deref(),
1844 i64::try_from(ingest_result.rows).ok(),
1845 );
1846
1847 Ok(json!({
1848 "rows": ingest_result.rows,
1849 "schema": schema_json,
1850 "stats": ingest_result.stats.to_json(),
1851 }))
1852 });
1853
1854 match result {
1855 Ok(val) => {
1856 self.notify_table_changed(&table_name);
1857 if mode == "replace" {
1858 self.notify_resource_list_changed();
1859 }
1860 Self::ok_content(val)
1861 }
1862 Err(e) => Self::err_content(e),
1863 }
1864 }
1865
1866 #[tool(
1868 description = "Run a read-only SQL query (SELECT, WITH, EXPLAIN, SHOW, VALUES) against the workspace. For DDL/DML use the execute tool."
1869 )]
1870 fn query(
1871 &self,
1872 Parameters(params): Parameters<QueryParams>,
1873 ) -> Result<CallToolResult, rmcp::ErrorData> {
1874 let result = self.with_engine(|engine| {
1875 if !is_read_only_sql(¶ms.sql) {
1876 return Err(McpError::new(
1877 ErrorCode::SqlError,
1878 "The query tool only accepts read-only SQL (SELECT, WITH, EXPLAIN, SHOW, VALUES). Use the execute tool for DDL/DML.",
1879 ));
1880 }
1881 const MAX_QUERY_ROWS: usize = 10_000;
1885
1886 let timer = crate::stats::StatsTimer::start();
1887 let mut rows = engine.execute_query_to_json(¶ms.sql)?;
1888 let total_rows = rows.len();
1889 let truncated = total_rows > MAX_QUERY_ROWS;
1890 if truncated {
1891 rows.truncate(MAX_QUERY_ROWS);
1892 }
1893 let elapsed = timer.elapsed_ms();
1894 let stats = crate::stats::QueryStats {
1895 operation: "query".into(),
1896 rows_returned: rows.len() as u64,
1897 rows_scanned: 0,
1898 elapsed_ms: elapsed,
1899 result_size_bytes: serde_json::to_string(&rows).map_or(0, |s| s.len() as u64),
1900 tables_touched: vec![],
1901 };
1902 let payload = if truncated {
1903 json!({
1904 "result": rows,
1905 "stats": stats.to_json(),
1906 "truncated": true,
1907 "total_rows": total_rows,
1908 "rows_returned": MAX_QUERY_ROWS,
1909 "hint": format!(
1910 "Result set has {total_rows} rows; only the first {MAX_QUERY_ROWS} \
1911 are shown. Add a LIMIT clause, aggregate with GROUP BY, or use \
1912 the `export` tool to write the full result to a file."
1913 ),
1914 })
1915 } else {
1916 json!({
1917 "result": rows,
1918 "stats": stats.to_json(),
1919 })
1920 };
1921 Ok((params.sql.clone(), payload))
1922 });
1923
1924 match result {
1925 Ok((sql, val)) => {
1926 let formatted_sql = Self::fmt_sql(&sql);
1927 let json_text = serde_json::to_string_pretty(&val).unwrap_or_default();
1928 Ok(CallToolResult::success(vec![
1929 Content::text(format!("```sql\n{formatted_sql}\n```")),
1930 Content::text(json_text),
1931 ]))
1932 }
1933 Err(e) => Self::err_content(e),
1934 }
1935 }
1936
1937 #[tool(
1939 description = "Execute a DDL/DML statement (CREATE TABLE, INSERT, UPDATE, DELETE, DROP, ALTER, COPY, etc.). Returns affected row count. Disabled in read-only mode."
1940 )]
1941 fn execute(
1942 &self,
1943 Parameters(params): Parameters<ExecuteParams>,
1944 ) -> Result<CallToolResult, rmcp::ErrorData> {
1945 if let Err(e) = self.check_writable("execute") {
1946 return Self::err_content(e);
1947 }
1948 let sql = params.sql.clone();
1949 let result = self.with_engine(|engine| {
1950 if is_read_only_sql(¶ms.sql) {
1951 return Err(McpError::new(
1952 ErrorCode::SqlError,
1953 "The execute tool is for DDL/DML. Use the query tool for SELECT/WITH/EXPLAIN statements.",
1954 ));
1955 }
1956 let timer = crate::stats::StatsTimer::start();
1957 let affected = engine.execute_command(¶ms.sql)?;
1958 let elapsed = timer.elapsed_ms();
1959 self.after_execute_catalog_update(engine);
1964 Ok(json!({
1965 "sql": Self::fmt_sql(¶ms.sql),
1966 "affected_rows": affected,
1967 "stats": { "operation": "command", "elapsed_ms": elapsed },
1968 }))
1969 });
1970
1971 match result {
1972 Ok(val) => {
1973 self.notify_workspace_changed();
1978 if is_structural_sql(&sql) {
1979 self.notify_resource_list_changed();
1980 }
1981 Self::ok_content(val)
1982 }
1983 Err(e) => Self::err_content(e),
1984 }
1985 }
1986
1987 #[tool(
1989 description = "Return the schema, total row count, and first N rows of a table. Combines describe + sample query in one call. N defaults to 5, max 100."
1990 )]
1991 fn sample(
1992 &self,
1993 Parameters(params): Parameters<SampleParams>,
1994 ) -> Result<CallToolResult, rmcp::ErrorData> {
1995 let result = self.with_engine(|engine| {
1996 let timer = crate::stats::StatsTimer::start();
1997 let n = params.n.unwrap_or(5);
1998 let mut sample = engine.sample_table(¶ms.table, n)?;
1999 let elapsed = timer.elapsed_ms();
2000 if let Some(obj) = sample.as_object_mut() {
2001 obj.insert(
2002 "stats".into(),
2003 json!({ "operation": "sample", "elapsed_ms": elapsed }),
2004 );
2005 }
2006 Ok(sample)
2007 });
2008
2009 match result {
2010 Ok(val) => Self::ok_content(val),
2011 Err(e) => Self::err_content(e),
2012 }
2013 }
2014
2015 #[tool(
2017 description = "Render a chart (bar, line, scatter, or histogram) from a SQL query. Writes the image to disk by default and returns a short stats blob with the path — use `Read(path)` to display it (this keeps the MCP transcript small). Set `inline=true` to also receive the PNG/SVG bytes inline in the tool result; combine with `output_path` to get both.\n\n- `output_path`: explicit destination file path. Parent directory is created automatically. If omitted, a file is auto-generated under the system temp dir as `hyperdb-charts/chart-<ts>-<pid>-<n>.<ext>`.\n- `inline`: when true, return the image bytes inline. Without `output_path`, suppresses the disk write entirely. With `output_path`, writes to disk AND returns inline. Defaults to false.\n- `format`: \"png\" (default) or \"svg\". Auto-derived from `output_path` extension when omitted. A mismatch between `format` and the path extension returns `INVALID_ARGUMENT`.\n- `overwrite`: default true. Set false to refuse overwriting an existing file (returns `PERMISSION_DENIED`).\n- `x_range` / `y_range`: fix axis extents across multiple charts (e.g. x_range=[0,1500], y_range=[0,1]).\n- `color_map`: stable per-series hex colors (e.g. {\"India\":\"#e41a1c\",\"China\":\"#ff7f0e\"}).\n- `label_points=true`: annotate each point with its series name instead of showing a legend — best when each series has exactly one point."
2018 )]
2019 fn chart(
2020 &self,
2021 Parameters(params): Parameters<ChartParams>,
2022 ) -> Result<CallToolResult, rmcp::ErrorData> {
2023 let result = self.with_engine(|engine| {
2024 if !is_read_only_sql(¶ms.sql) {
2025 return Err(McpError::new(
2026 ErrorCode::SqlError,
2027 "The chart tool only accepts read-only SQL (SELECT, WITH, EXPLAIN, SHOW, VALUES).",
2028 ));
2029 }
2030
2031 if let Some(out) = params.output_path.as_deref() {
2034 crate::attach::validate_output_path(out, "chart output")?;
2035 }
2036 let format = crate::chart::resolve_chart_format(
2039 params.format.as_deref(),
2040 params.output_path.as_deref(),
2041 )?;
2042
2043 let timer = crate::stats::StatsTimer::start();
2044 let rows = engine.execute_query_to_json(¶ms.sql)?;
2045
2046 let color_map = params
2049 .color_map
2050 .as_ref()
2051 .map(|m| {
2052 m.iter()
2053 .filter_map(|(k, v)| {
2054 crate::chart::parse_hex_color(v)
2055 .map(|c| (k.clone(), c))
2056 })
2057 .collect::<std::collections::HashMap<_, _>>()
2058 })
2059 .unwrap_or_default();
2060
2061 let opts = ChartOptions {
2062 chart_type: ChartType::parse(¶ms.chart_type)?,
2063 x_column: params.x.clone(),
2064 y_column: params.y.clone(),
2065 series_column: params.series.clone(),
2066 title: params.title.clone(),
2067 format,
2068 width: params.width.unwrap_or(800).clamp(200, 4096),
2069 height: params.height.unwrap_or(480).clamp(150, 4096),
2070 bins: params.bins.unwrap_or(20).clamp(1, 500),
2071 x_as_category: params.x_as_category,
2072 x_range: params.x_range,
2073 y_range: params.y_range,
2074 color_map,
2075 label_points: params.label_points.unwrap_or(false),
2076 };
2077
2078 let chart = render_chart(&rows, &opts)?;
2079
2080 let disposition = crate::chart::resolve_chart_disposition(
2084 params.inline.unwrap_or(false),
2085 params.output_path.as_deref(),
2086 opts.format,
2087 );
2088 let overwrite = params.overwrite.unwrap_or(true);
2089 if let Some(path) = disposition.path() {
2090 crate::chart::write_chart_to_disk(path, &chart.bytes, overwrite)?;
2091 }
2092
2093 let elapsed = timer.elapsed_ms();
2094 Ok((chart, elapsed, opts, disposition))
2095 });
2096
2097 match result {
2098 Ok((chart, elapsed_ms, opts, disposition)) => {
2099 let format_str = match opts.format {
2100 ChartFormat::Png => "png",
2101 ChartFormat::Svg => "svg",
2102 };
2103 let wants_inline = disposition.wants_inline();
2104 let output_path_str = disposition.path().map(|p| p.to_string_lossy().into_owned());
2105
2106 let mut stats = serde_json::Map::new();
2107 stats.insert("operation".into(), json!("chart"));
2108 stats.insert("rows_plotted".into(), json!(chart.rows_plotted));
2109 stats.insert("elapsed_ms".into(), json!(elapsed_ms));
2110 stats.insert("format".into(), json!(format_str));
2111 stats.insert("bytes".into(), json!(chart.bytes.len()));
2112 stats.insert("width".into(), json!(opts.width));
2113 stats.insert("height".into(), json!(opts.height));
2114 stats.insert("inline".into(), json!(wants_inline));
2115 if let Some(p) = output_path_str {
2116 stats.insert("output_path".into(), json!(p));
2117 }
2118 let stats_text =
2119 serde_json::to_string_pretty(&Value::Object(stats)).unwrap_or_default();
2120
2121 let mut content = Vec::with_capacity(2);
2122 if wants_inline {
2123 let b64 = base64::engine::general_purpose::STANDARD.encode(&chart.bytes);
2124 content.push(Content::image(b64, chart.mime_type.to_string()));
2125 }
2126 content.push(Content::text(stats_text));
2127 Ok(CallToolResult::success(content))
2128 }
2129 Err(e) => Self::err_content(e),
2130 }
2131 }
2132
2133 #[tool(
2136 description = "Watch a directory for files to auto-ingest. Producers write data file + companion <name>.ready sentinel; the watcher appends the data file to the given table and deletes both on success. Disabled in read-only mode."
2137 )]
2138 fn watch_directory(
2139 &self,
2140 Parameters(params): Parameters<WatchDirectoryParams>,
2141 ) -> Result<CallToolResult, rmcp::ErrorData> {
2142 if let Err(e) = self.check_writable("watch_directory") {
2143 return Self::err_content(e);
2144 }
2145 let canonical = match crate::attach::validate_input_path(¶ms.path, "watch directory") {
2146 Ok(p) => p,
2147 Err(e) => return Self::err_content(e),
2148 };
2149 match self.ensure_engine() {
2152 Ok(guard) => drop(guard),
2153 Err(e) => return Self::err_content(e),
2154 }
2155
2156 let path = canonical;
2157 let engine_handle = self.engine_handle();
2158 let registry = self.watchers_handle();
2159 let options = crate::watcher::WatchOptions {
2160 max_concurrent: params.max_concurrent.unwrap_or(0) as usize,
2161 };
2162 let result = crate::watcher::start_watching(
2163 engine_handle,
2164 registry,
2165 Some(self.subscriptions_handle()),
2166 path.clone(),
2167 params.table.clone(),
2168 options,
2169 );
2170 match result {
2171 Ok(stats) => {
2172 let body = json!({
2173 "directory": path.to_string_lossy(),
2174 "table": params.table,
2175 "status": "watching",
2176 "max_concurrent": stats.max_concurrent,
2177 "initial_sweep": {
2178 "files_ingested": stats.files_ingested,
2179 "files_failed": stats.files_failed,
2180 },
2181 });
2182 Self::ok_content(body)
2183 }
2184 Err(e) => Self::err_content(e),
2185 }
2186 }
2187
2188 #[tool(
2190 description = "Stop watching a directory previously registered with watch_directory. Pending .ready files are left in place."
2191 )]
2192 fn unwatch_directory(
2193 &self,
2194 Parameters(params): Parameters<UnwatchDirectoryParams>,
2195 ) -> Result<CallToolResult, rmcp::ErrorData> {
2196 let path = std::path::PathBuf::from(¶ms.path);
2197 let result = crate::watcher::stop_watching(&self.watchers_handle(), &path);
2198 match result {
2199 Ok(summary) => Self::ok_content(summary),
2200 Err(e) => Self::err_content(e),
2201 }
2202 }
2203
2204 #[tool(
2207 description = "Describe workspace tables. With `table` set, returns that single table's columns and row count (TABLE_NOT_FOUND if missing). Without `table`, lists every public table."
2208 )]
2209 fn describe(
2210 &self,
2211 Parameters(params): Parameters<DescribeParams>,
2212 ) -> Result<CallToolResult, rmcp::ErrorData> {
2213 let result = self.with_engine(|engine| match params.table.as_deref() {
2214 Some(name) => engine.describe_table(name).map(|t| vec![t]),
2215 None => engine.describe_tables(),
2216 });
2217
2218 match result {
2219 Ok(tables) => Self::ok_content(json!({"tables": tables})),
2220 Err(e) => Self::err_content(e),
2221 }
2222 }
2223
2224 #[tool(
2229 description = "Dry-run schema inference on a CSV / Parquet / Arrow IPC file without ingesting. Returns the schema load_file would use (including the full-file numeric widening pass), plus per-column null_count, min, max, and sample_values. Use this BEFORE load_file if you are unsure about types or ran into a SchemaMismatch / numeric overflow — then pass an explicit `schema` override on the subsequent load_file call. Use `json_extract_path` to inspect a nested data array inside a JSON wrapper file (e.g., MCP tool responses saved to disk)."
2230 )]
2231 #[expect(
2232 clippy::unused_self,
2233 reason = "method retained on the type for API symmetry; implementation currently does not need state"
2234 )]
2235 fn inspect_file(
2236 &self,
2237 Parameters(params): Parameters<InspectFileParams>,
2238 ) -> Result<CallToolResult, rmcp::ErrorData> {
2239 if let Err(e) = crate::attach::validate_input_path(¶ms.path, "data file") {
2240 return Self::err_content(e);
2241 }
2242 let sample_rows = params.sample_rows.unwrap_or(5).clamp(1, 50) as usize;
2243 let result = if let Some(ref json_path) = params.json_extract_path {
2244 (|| -> Result<_, McpError> {
2245 let raw = std::fs::read_to_string(¶ms.path).map_err(|e| {
2246 McpError::new(
2247 ErrorCode::FileNotFound,
2248 format!("Cannot read file '{}': {e}", params.path),
2249 )
2250 })?;
2251 let file_size = std::fs::metadata(¶ms.path).map_or(0, |m| m.len());
2252 let extracted = crate::ingest::extract_json_path(&raw, json_path)?;
2253 crate::inspect::inspect_json_from_text(&extracted, file_size, sample_rows)
2254 })()
2255 } else {
2256 crate::inspect::inspect_source(¶ms.path, sample_rows)
2257 };
2258 match result {
2259 Ok(report) => Self::ok_content(report.to_json()),
2260 Err(e) => Self::err_content(e),
2261 }
2262 }
2263
2264 #[tool(
2267 description = "Export query results or a table to a file via hyperd's native writers. Every format listed here is server-side — hyperd writes the file directly, with zero per-row work in the MCP process — and every format round-trips cleanly through the matching loader (`load_file` or `load_iceberg`).\n\nWhen choosing a format for *data leaving* Hyper, prefer in this order:\n 1. **Parquet** (recommended default): smallest output, fastest write, preserves every type (NUMERIC precision/scale, DATE, TIMESTAMP, etc.). `path` is a single file.\n 2. **Iceberg**: produces a full Apache Iceberg table directory (`metadata/` + `data/`). Use when the consumer is a data-lake tool (Spark, Trino, DuckDB, etc.). `path` is a directory that hyperd creates.\n 3. **Arrow IPC Stream** (`arrow_ipc`): same wire shape Hyper uses internally; great for handing data to another Arrow-aware process. Larger than Parquet (no compression) but extremely fast to read back. `path` is a single file.\n 4. **CSV**: portable and human-readable but the largest output and types are lost (everything becomes text). Use for spreadsheet / shell-pipeline interop. Includes header row.\n 5. **Hyper**: an entire `.hyper` database file openable directly in Tableau Desktop. `sql`/`table` are ignored — every user table is copied.\n\nAll formats except Iceberg and Hyper require either `sql` or `table`. Iceberg output is a directory; all others are single files."
2268 )]
2269 fn export(
2270 &self,
2271 Parameters(params): Parameters<ExportParams>,
2272 ) -> Result<CallToolResult, rmcp::ErrorData> {
2273 let result = self.with_engine(|engine| {
2274 crate::attach::validate_output_path(¶ms.path, "export")?;
2277 let format_options = match params.format_options.clone() {
2281 None => None,
2282 Some(Value::Object(m)) => Some(m),
2283 Some(other) => {
2284 return Err(McpError::new(
2285 ErrorCode::SchemaMismatch,
2286 format!("export: format_options must be a JSON object, got: {other}"),
2287 ));
2288 }
2289 };
2290 let opts = ExportOptions {
2291 sql: params.sql,
2292 table: params.table,
2293 path: params.path,
2294 format: params.format,
2295 overwrite: params.overwrite.unwrap_or(true),
2296 format_options,
2297 };
2298 let export_result = export_to_file(engine, &opts)?;
2299 Ok(json!({
2300 "output_path": export_result.stats.output_path,
2301 "rows": export_result.rows,
2302 "file_size_bytes": export_result.stats.file_size_bytes,
2303 "stats": export_result.stats.to_json(),
2304 }))
2305 });
2306
2307 match result {
2308 Ok(val) => Self::ok_content(val),
2309 Err(e) => Self::err_content(e),
2310 }
2311 }
2312
2313 #[tool(
2317 description = "Save a named read-only SQL query. Creates two resources: `hyper://queries/{name}/definition` (sql + metadata JSON) and `hyper://queries/{name}/result` (re-runs the SQL on every read). Persisted in the workspace when `--workspace` is set; session-only otherwise. Rejects non-read-only SQL and duplicate names; delete first to overwrite."
2318 )]
2319 fn save_query(
2320 &self,
2321 Parameters(params): Parameters<SaveQueryParams>,
2322 ) -> Result<CallToolResult, rmcp::ErrorData> {
2323 if let Err(e) = self.check_writable("save_query") {
2324 return Self::err_content(e);
2325 }
2326 if !is_read_only_sql(¶ms.sql) {
2331 return Self::err_content(McpError::new(
2332 ErrorCode::SqlError,
2333 "save_query only accepts read-only SQL (SELECT / WITH / EXPLAIN / SHOW / VALUES). \
2334 Use the execute tool for DDL/DML, not save_query.",
2335 ));
2336 }
2337 if params.name.is_empty() {
2338 return Self::err_content(McpError::new(
2339 ErrorCode::SchemaMismatch,
2340 "Saved query name must not be empty.",
2341 ));
2342 }
2343 let query = SavedQuery {
2344 name: params.name.clone(),
2345 sql: params.sql,
2346 description: params.description,
2347 created_at: chrono::Utc::now(),
2348 };
2349 let store = Arc::clone(&self.saved_queries);
2350 let result = self.with_saved_query_store(|engine| store.save(engine, query.clone()));
2351 match result {
2352 Ok(()) => {
2353 self.notify_resource_list_changed();
2357 Self::ok_content(json!({
2358 "saved": true,
2359 "name": query.name,
2360 "resources": [
2361 format!("hyper://queries/{}/definition", query.name),
2362 format!("hyper://queries/{}/result", query.name),
2363 ],
2364 "created_at": query.created_at.to_rfc3339(),
2365 }))
2366 }
2367 Err(e) => Self::err_content(e),
2368 }
2369 }
2370
2371 #[tool(
2373 description = "Delete a named saved query. Removes the underlying entry and both `hyper://queries/{name}/...` resources. Returns `{deleted: true}` when the query existed, `{deleted: false}` when it did not (no error)."
2374 )]
2375 fn delete_query(
2376 &self,
2377 Parameters(params): Parameters<DeleteQueryParams>,
2378 ) -> Result<CallToolResult, rmcp::ErrorData> {
2379 if let Err(e) = self.check_writable("delete_query") {
2380 return Self::err_content(e);
2381 }
2382 let store = Arc::clone(&self.saved_queries);
2383 let name = params.name.clone();
2384 let result = self.with_saved_query_store(|engine| store.delete(engine, &name));
2385 match result {
2386 Ok(deleted) => {
2387 if deleted {
2388 self.notify_resource_list_changed();
2393 self.subscriptions
2394 .notify_updated(&format!("hyper://queries/{name}/definition"));
2395 self.subscriptions
2396 .notify_updated(&format!("hyper://queries/{name}/result"));
2397 }
2398 Self::ok_content(json!({
2399 "deleted": deleted,
2400 "name": params.name,
2401 }))
2402 }
2403 Err(e) => Self::err_content(e),
2404 }
2405 }
2406
2407 #[tool(
2409 description = "Update prose metadata for a table in the `_table_catalog`: source_url, source_description, purpose, license, notes. Fields you omit stay unchanged; pass an explicit empty string (\"\") to clear a field. Mechanical fields (load_tool, load_params, loaded_at, last_refreshed_at, row_count) are managed by the server. Requires an existing catalog entry — load the table first (load_file / load_data / execute CREATE TABLE) so the stub row is created automatically. Disabled in read-only and --bare mode."
2410 )]
2411 fn set_table_metadata(
2412 &self,
2413 Parameters(params): Parameters<SetTableMetadataParams>,
2414 ) -> Result<CallToolResult, rmcp::ErrorData> {
2415 if let Err(e) = self.check_writable("set_table_metadata") {
2416 return Self::err_content(e);
2417 }
2418 if self.bare {
2419 return Self::err_content(McpError::new(
2420 ErrorCode::ReadOnlyViolation,
2421 "set_table_metadata is disabled in --bare mode because the \
2422 _table_catalog is never created. Restart without --bare if \
2423 you want per-table provenance tracking.",
2424 ));
2425 }
2426 let fields = crate::table_catalog::MetadataFields {
2427 source_url: params.source_url,
2428 source_description: params.source_description,
2429 purpose: params.purpose,
2430 license: params.license,
2431 notes: params.notes,
2432 };
2433 let table_name = params.table.clone();
2434 let result = self
2435 .with_engine(|engine| crate::table_catalog::set_metadata(engine, &table_name, &fields));
2436 match result {
2437 Ok(entry) => Self::ok_content(entry.to_json()),
2438 Err(e) => Self::err_content(e),
2439 }
2440 }
2441
2442 #[tool(
2445 description = "Returns plugin health, workspace info, table count, total rows, disk usage, and active directory watchers."
2446 )]
2447 fn status(&self) -> Result<CallToolResult, rmcp::ErrorData> {
2448 let result = self.with_engine(super::engine::Engine::status);
2449
2450 match result {
2451 Ok(mut val) => {
2452 if let Some(obj) = val.as_object_mut() {
2453 obj.insert("watchers".into(), self.watchers.to_json());
2454 obj.insert("read_only".into(), json!(self.read_only));
2455 let attachments: Vec<Value> = self
2456 .attachments
2457 .list()
2458 .iter()
2459 .map(super::attach::AttachedDb::to_json)
2460 .collect();
2461 obj.insert("attachments".into(), Value::Array(attachments));
2462 }
2463 Self::ok_content(val)
2464 }
2465 Err(e) => Self::err_content(e),
2466 }
2467 }
2468
2469 #[tool(
2474 description = "Returns a concise LLM-facing README explaining what this MCP does, which tool to use for what, key parameter rules, SQL dialect quirks, and usage examples. Call this once at the start of a session to ground the model in the surface area before issuing other tool calls."
2475 )]
2476 #[expect(
2477 clippy::unused_self,
2478 reason = "the #[tool] macro dispatches on &self; signature must match the rest of the tool surface even though this tool is stateless"
2479 )]
2480 #[expect(
2481 clippy::unnecessary_wraps,
2482 reason = "uniform Result<CallToolResult, rmcp::ErrorData> across all tools so the #[tool_router] dispatcher has one signature shape"
2483 )]
2484 fn get_readme(&self) -> Result<CallToolResult, rmcp::ErrorData> {
2485 Ok(CallToolResult::success(vec![Content::text(
2486 crate::readme::README,
2487 )]))
2488 }
2489
2490 #[tool(
2493 description = "Attach an additional .hyper database under a chosen alias. Tables in the attachment are addressable as `{alias}.public.{table}` in any subsequent SELECT; tables in the primary workspace remain addressable as `local.public.{table}` or by their file stem. Default is read-only; pass writable:true to allow mutations (still respects --read-only). Set on_missing='create' (with writable:true) to create an empty .hyper file at the target path first and then attach it — useful for scratch databases without a separate file-creation step; the parent directory must already exist. Only kind='local_file' is supported today; 'tcp' and 'grpc' (Data 360) are planned. The alias 'local' is reserved for the primary workspace."
2494 )]
2495 fn attach_database(
2496 &self,
2497 Parameters(params): Parameters<AttachDatabaseParams>,
2498 ) -> Result<CallToolResult, rmcp::ErrorData> {
2499 let writable = params.writable.unwrap_or(false);
2500 if writable {
2501 if let Err(e) = self.check_writable("attach_database(writable)") {
2502 return Self::err_content(e);
2503 }
2504 }
2505 let on_missing = match attach::OnMissing::parse(params.on_missing.as_deref()) {
2506 Ok(v) => v,
2507 Err(e) => return Self::err_content(e),
2508 };
2509 if on_missing == attach::OnMissing::Create && !writable {
2510 return Self::err_content(McpError::new(
2511 ErrorCode::InvalidArgument,
2512 "on_missing='create' requires writable:true — an empty .hyper file that cannot be written to cannot be populated.",
2513 ));
2514 }
2515 let source = match params.kind.as_str() {
2516 "local_file" => {
2517 let Some(raw) = params.path.as_deref() else {
2518 return Self::err_content(McpError::new(
2519 ErrorCode::InvalidArgument,
2520 "kind='local_file' requires a 'path' argument",
2521 ));
2522 };
2523 let resolved = match on_missing {
2524 attach::OnMissing::Error => attach::validate_local_path(raw),
2525 attach::OnMissing::Create => attach::validate_local_path_for_create(raw),
2526 };
2527 match resolved {
2528 Ok(canonical) => AttachSource::LocalFile { path: canonical },
2529 Err(e) => return Self::err_content(e),
2530 }
2531 }
2532 other => {
2533 return Self::err_content(McpError::new(
2534 ErrorCode::InvalidArgument,
2535 format!(
2536 "Unsupported attach kind '{other}'. Only 'local_file' is supported today; \
2537 'tcp' (remote hyperd) and 'grpc' (Data 360) are planned."
2538 ),
2539 ));
2540 }
2541 };
2542 let req = AttachRequest {
2543 alias: params.alias.clone(),
2544 source,
2545 writable,
2546 on_missing,
2547 };
2548 let registry = self.attachments_handle();
2549 let alias_for_probe = req.alias.clone();
2550 let result = self.with_engine(|engine| {
2551 let entry = registry.attach(engine, req.clone())?;
2552 let tables_visible = probe_table_count(engine, &alias_for_probe);
2557 Ok(json!({
2558 "alias": entry.alias,
2559 "kind": entry.source.kind_str(),
2560 "source": entry.source.to_json(),
2561 "writable": entry.writable,
2562 "tables_visible": tables_visible,
2563 }))
2564 });
2565 match result {
2566 Ok(val) => Self::ok_content(val),
2567 Err(e) => Self::err_content(e),
2568 }
2569 }
2570
2571 #[tool(
2573 description = "Detach a database previously registered with attach_database. No-op when the alias is unknown. Returns {detached: true/false}."
2574 )]
2575 fn detach_database(
2576 &self,
2577 Parameters(params): Parameters<DetachDatabaseParams>,
2578 ) -> Result<CallToolResult, rmcp::ErrorData> {
2579 let alias = params.alias.clone();
2580 let registry = self.attachments_handle();
2581 let result = self.with_engine(|engine| registry.detach(engine, &alias));
2582 match result {
2583 Ok(detached) => {
2584 Self::ok_content(json!({ "alias": params.alias, "detached": detached }))
2585 }
2586 Err(e) => Self::err_content(e),
2587 }
2588 }
2589
2590 #[tool(
2600 description = "List every database currently attached under an alias: kind, path/endpoint, writable flag, attach time, and (best-effort) a count of visible public-schema tables."
2601 )]
2602 fn list_attached_databases(&self) -> Result<CallToolResult, rmcp::ErrorData> {
2603 let result = self.with_engine(|engine| {
2604 let entries = self.attachments.list();
2605 let attachments: Vec<Value> = entries
2606 .iter()
2607 .map(|entry| {
2608 let mut obj = entry.to_json();
2609 let tables_visible = probe_table_count(engine, &entry.alias);
2610 if let Some(map) = obj.as_object_mut() {
2611 map.insert("tables_visible".into(), json!(tables_visible));
2612 }
2613 obj
2614 })
2615 .collect();
2616 Ok(json!({ "attachments": attachments }))
2617 });
2618 match result {
2619 Ok(val) => Self::ok_content(val),
2620 Err(e) => Self::err_content(e),
2621 }
2622 }
2623
2624 #[tool(
2629 description = "Run a SELECT (or WITH / VALUES) across local and attached databases and insert the result into a target table. Required `mode`: 'create' (target must not exist, creates via CREATE TABLE AS), 'append' (target must exist, INSERT INTO ... SELECT), or 'replace' (drops and recreates atomically). `target_database` defaults to the primary workspace ('local' also accepted); any other value must be an attachment registered with writable:true. Optional `temp_attach` attaches additional databases for this call only and detaches them on exit (even on failure). Disabled in read-only mode."
2630 )]
2631 fn copy_query(
2632 &self,
2633 Parameters(params): Parameters<CopyQueryParams>,
2634 ) -> Result<CallToolResult, rmcp::ErrorData> {
2635 if let Err(e) = self.check_writable("copy_query") {
2636 return Self::err_content(e);
2637 }
2638 let mode = match params.mode.as_str() {
2639 "create" | "append" | "replace" => params.mode.clone(),
2640 other => {
2641 return Self::err_content(McpError::new(
2642 ErrorCode::InvalidArgument,
2643 format!(
2644 "copy_query mode '{other}' is not supported. Use 'create', 'append', or 'replace'."
2645 ),
2646 ));
2647 }
2648 };
2649 if !is_read_only_sql(¶ms.sql) {
2650 return Self::err_content(McpError::new(
2651 ErrorCode::SqlError,
2652 "copy_query's `sql` must be a read-only statement (SELECT / WITH / VALUES). \
2653 Use the execute tool for raw DDL/DML.",
2654 ));
2655 }
2656 let target_db = params
2660 .target_database
2661 .as_deref()
2662 .filter(|s| !s.eq_ignore_ascii_case(LOCAL_ALIAS));
2663 if let Some(alias) = target_db {
2664 match self.attachments.get(alias) {
2665 None => {
2666 return Self::err_content(McpError::new(
2667 ErrorCode::InvalidArgument,
2668 format!(
2669 "target_database '{alias}' is not attached. Call attach_database first."
2670 ),
2671 ));
2672 }
2673 Some(entry) if !entry.writable => {
2674 return Self::err_content(McpError::new(
2675 ErrorCode::InvalidArgument,
2676 format!(
2677 "target_database '{alias}' was attached read-only. Re-attach with writable:true to use it as a copy target."
2678 ),
2679 ));
2680 }
2681 Some(_) => {}
2682 }
2683 }
2684
2685 let temp_specs = params.temp_attach.clone().unwrap_or_default();
2688 let prepared_temps = match prepare_temp_attachments(&temp_specs, self.is_read_only()) {
2689 Ok(v) => v,
2690 Err(e) => return Self::err_content(e),
2691 };
2692
2693 let target_table = params.target_table.clone();
2694 let sql_body = params.sql.clone();
2695 let load_params = serde_json::to_string(&json!({
2696 "mode": mode,
2697 "target_database": params.target_database,
2698 "target_table": target_table,
2699 "sql": Self::fmt_sql(&sql_body),
2700 }))
2701 .ok();
2702
2703 let registry = self.attachments_handle();
2704 let result = self.with_engine(|engine| {
2705 let mut temp_aliases: Vec<String> = Vec::new();
2707 for req in &prepared_temps {
2708 match registry.attach(engine, req.clone()) {
2709 Ok(entry) => temp_aliases.push(entry.alias),
2710 Err(e) => {
2711 for alias in &temp_aliases {
2713 let _ = registry.detach(engine, alias);
2714 }
2715 return Err(e);
2716 }
2717 }
2718 }
2719
2720 let copy_outcome = perform_copy(engine, &mode, target_db, &target_table, &sql_body);
2723
2724 for alias in &temp_aliases {
2728 if let Err(e) = registry.detach(engine, alias) {
2729 tracing::warn!(
2730 alias = %alias,
2731 err = %e.message,
2732 "failed to detach temp attachment after copy_query",
2733 );
2734 }
2735 }
2736
2737 if copy_outcome.is_ok() && target_db.is_none() {
2748 let row_count = copy_outcome
2749 .as_ref()
2750 .ok()
2751 .and_then(|v| v.get("row_count").and_then(serde_json::Value::as_i64));
2752 self.after_ingest_catalog_update(
2753 engine,
2754 &target_table,
2755 "copy_query",
2756 load_params.as_deref(),
2757 row_count,
2758 );
2759 }
2760
2761 copy_outcome
2762 });
2763
2764 match result {
2765 Ok(outcome) => {
2766 if target_db.is_none() {
2768 self.notify_table_changed(&target_table);
2769 }
2770 self.notify_workspace_changed();
2771 if mode != "append" {
2772 self.notify_resource_list_changed();
2775 }
2776 Self::ok_content(outcome)
2777 }
2778 Err(e) => Self::err_content(e),
2779 }
2780 }
2781}
2782
2783#[prompt_router]
2786impl HyperMcpServer {
2787 #[prompt(
2789 name = "analyze-table",
2790 description = "Deep analysis of a single table: schema, sample, column stats, data quality"
2791 )]
2792 pub async fn analyze_table(
2793 &self,
2794 Parameters(args): Parameters<AnalyzeTableArgs>,
2795 ) -> Vec<PromptMessage> {
2796 let context = self.build_analyze_context(&args.table);
2797 vec![
2798 PromptMessage::new_text(
2799 PromptMessageRole::User,
2800 format!(
2801 "Analyze the `{}` table thoroughly.\n\n{}\n\nPlease:\n\
2802 1. Describe each column (what it likely represents based on name and sample values)\n\
2803 2. Compute basic statistics using the query tool: min/max/avg for numeric columns, distinct count and top values for text columns\n\
2804 3. Flag any data quality issues: unexpected NULLs, suspicious outliers, inconsistent formats\n\
2805 4. Summarize your findings in plain English",
2806 args.table, context
2807 ),
2808 ),
2809 PromptMessage::new_text(
2810 PromptMessageRole::Assistant,
2811 format!(
2812 "I'll analyze the `{}` table systematically. Let me start by examining the schema and sample, then run targeted queries for statistics and data quality.",
2813 args.table
2814 ),
2815 ),
2816 ]
2817 }
2818
2819 #[prompt(
2821 name = "compare-tables",
2822 description = "Compare two tables: schema alignment, common keys, JOIN opportunities"
2823 )]
2824 pub async fn compare_tables(
2825 &self,
2826 Parameters(args): Parameters<CompareTablesArgs>,
2827 ) -> Vec<PromptMessage> {
2828 let ctx_a = self.build_brief_context(&args.table_a);
2829 let ctx_b = self.build_brief_context(&args.table_b);
2830 vec![
2831 PromptMessage::new_text(
2832 PromptMessageRole::User,
2833 format!(
2834 "Compare these two tables:\n\n## Table A: `{}`\n{}\n\n## Table B: `{}`\n{}\n\nPlease:\n\
2835 1. Identify columns that appear in both tables (by name or semantic match)\n\
2836 2. Suggest likely JOIN keys and the JOIN type (inner, left, etc.)\n\
2837 3. Highlight schema differences (column types, nullability)\n\
2838 4. Propose 3-5 analytical queries that combine both tables and explain what each reveals",
2839 args.table_a, ctx_a, args.table_b, ctx_b
2840 ),
2841 ),
2842 PromptMessage::new_text(
2843 PromptMessageRole::Assistant,
2844 format!(
2845 "I'll compare `{}` and `{}` systematically — schema alignment first, then join keys, then analytical opportunities.",
2846 args.table_a, args.table_b
2847 ),
2848 ),
2849 ]
2850 }
2851
2852 #[prompt(
2854 name = "data-quality",
2855 description = "Systematic data quality assessment: NULL rates, duplicates, low cardinality, outliers"
2856 )]
2857 pub async fn data_quality(
2858 &self,
2859 Parameters(args): Parameters<DataQualityArgs>,
2860 ) -> Vec<PromptMessage> {
2861 let context = self.build_brief_context(&args.table);
2862 vec![
2863 PromptMessage::new_text(
2864 PromptMessageRole::User,
2865 format!(
2866 "Run a data quality assessment on the `{}` table.\n\n{}\n\nPlease use the query tool to check:\n\
2867 1. NULL rate per column — run SELECT COUNT(*) FILTER (WHERE col IS NULL) / COUNT(*) for each column\n\
2868 2. Duplicate rows — compare COUNT(*) vs COUNT(DISTINCT *) or use GROUP BY\n\
2869 3. Low-cardinality columns — columns with suspiciously few distinct values\n\
2870 4. Numeric outliers — values more than 3 stddev from the mean\n\
2871 5. Date sanity — future dates or impossibly old dates in date/timestamp columns\n\n\
2872 Summarize findings with severity (critical / warning / info) and suggest remediation for each issue.",
2873 args.table, context
2874 ),
2875 ),
2876 PromptMessage::new_text(
2877 PromptMessageRole::Assistant,
2878 format!(
2879 "I'll perform a systematic data quality assessment on `{}`. Let me run targeted queries for each check category.",
2880 args.table
2881 ),
2882 ),
2883 ]
2884 }
2885
2886 #[prompt(
2888 name = "suggest-queries",
2889 description = "Suggest analytical SQL queries for a table, optionally guided by a goal"
2890 )]
2891 pub async fn suggest_queries(
2892 &self,
2893 Parameters(args): Parameters<SuggestQueriesArgs>,
2894 ) -> Vec<PromptMessage> {
2895 let context = self.build_analyze_context(&args.table);
2896 let goal_section = match args.goal.as_deref() {
2897 Some(g) if !g.is_empty() => format!("\n\nSpecific goal: {g}"),
2898 _ => String::new(),
2899 };
2900 vec![
2901 PromptMessage::new_text(
2902 PromptMessageRole::User,
2903 format!(
2904 "Given the `{}` table:\n\n{}{}\n\nSuggest 5 analytical SQL queries that would be useful for exploring this data. \
2905 For each query, provide:\n\
2906 - A descriptive title\n\
2907 - The exact SQL (valid for Hyper / PostgreSQL-compatible syntax)\n\
2908 - One sentence explaining what insight it reveals\n\n\
2909 Prefer queries that use aggregations, GROUP BY, window functions, or CTEs to demonstrate the power of SQL analytics.",
2910 args.table, context, goal_section
2911 ),
2912 ),
2913 PromptMessage::new_text(
2914 PromptMessageRole::Assistant,
2915 format!(
2916 "Based on the schema and sample of `{}`, here are 5 analytical queries.",
2917 args.table
2918 ),
2919 ),
2920 ]
2921 }
2922}
2923
2924#[derive(Debug, Clone)]
2933pub enum ResourceBody {
2934 Json(Value),
2936 Text {
2939 mime_type: String,
2941 content: String,
2943 },
2944}
2945
2946impl ResourceBody {
2947 #[must_use]
2949 pub fn mime_type(&self) -> &str {
2950 match self {
2951 ResourceBody::Json(_) => "application/json",
2952 ResourceBody::Text { mime_type, .. } => mime_type,
2953 }
2954 }
2955
2956 #[must_use]
2959 pub fn to_text(&self) -> String {
2960 match self {
2961 ResourceBody::Json(v) => {
2962 serde_json::to_string_pretty(v).unwrap_or_else(|_| v.to_string())
2963 }
2964 ResourceBody::Text { content, .. } => content.clone(),
2965 }
2966 }
2967
2968 #[must_use]
2971 pub fn as_json(&self) -> Option<&Value> {
2972 match self {
2973 ResourceBody::Json(v) => Some(v),
2974 ResourceBody::Text { .. } => None,
2975 }
2976 }
2977}
2978
2979impl HyperMcpServer {
2980 pub fn resource_body_for_uri(&self, uri: &str) -> Result<Option<ResourceBody>, McpError> {
2999 if uri == "hyper://workspace" {
3000 return self
3001 .with_engine(super::engine::Engine::status)
3002 .map(|v| Some(ResourceBody::Json(v)));
3003 }
3004 if uri == "hyper://tables" {
3005 return self
3006 .with_engine(|engine| {
3007 engine
3008 .describe_tables()
3009 .map(|tables| json!({ "tables": tables }))
3010 })
3011 .map(|v| Some(ResourceBody::Json(v)));
3012 }
3013 if uri == "hyper://readme" {
3014 return self.build_readme_body().map(Some);
3015 }
3016 if let Some(name) = uri
3017 .strip_prefix("hyper://tables/")
3018 .and_then(|rest| rest.strip_suffix("/schema"))
3019 {
3020 let name = name.to_string();
3021 return self
3022 .with_engine(|engine| {
3023 let tables = engine.describe_tables()?;
3024 tables
3025 .into_iter()
3026 .find(|t| t.get("name").and_then(|v| v.as_str()) == Some(name.as_str()))
3027 .ok_or_else(|| {
3028 McpError::new(
3029 ErrorCode::TableNotFound,
3030 format!("Table '{name}' does not exist"),
3031 )
3032 })
3033 })
3034 .map(|v| Some(ResourceBody::Json(v)));
3035 }
3036 if let Some(name) = uri
3037 .strip_prefix("hyper://tables/")
3038 .and_then(|rest| rest.strip_suffix("/sample"))
3039 {
3040 let name = name.to_string();
3041 return self
3042 .with_engine(|engine| engine.sample_table(&name, TABLE_SAMPLE_ROWS))
3043 .map(|v| Some(ResourceBody::Json(v)));
3044 }
3045 if let Some(name) = uri
3046 .strip_prefix("hyper://tables/")
3047 .and_then(|rest| rest.strip_suffix("/csv-sample"))
3048 {
3049 let name = name.to_string();
3050 return self.build_csv_sample_body(&name).map(Some);
3051 }
3052 if let Some(name) = uri
3053 .strip_prefix("hyper://queries/")
3054 .and_then(|rest| rest.strip_suffix("/definition"))
3055 {
3056 return self.build_saved_query_definition(name).map(Some);
3057 }
3058 if let Some(name) = uri
3059 .strip_prefix("hyper://queries/")
3060 .and_then(|rest| rest.strip_suffix("/result"))
3061 {
3062 return self.build_saved_query_result(name).map(Some);
3063 }
3064 Ok(None)
3065 }
3066
3067 fn build_saved_query_definition(&self, name: &str) -> Result<ResourceBody, McpError> {
3071 let store = Arc::clone(&self.saved_queries);
3072 let name = name.to_string();
3073 let query = self.with_saved_query_store(|engine| store.get(engine, &name))?;
3074 match query {
3075 Some(q) => Ok(ResourceBody::Json(q.to_json())),
3076 None => Err(McpError::new(
3077 ErrorCode::TableNotFound,
3078 format!("No saved query named '{name}'"),
3079 )),
3080 }
3081 }
3082
3083 fn build_saved_query_result(&self, name: &str) -> Result<ResourceBody, McpError> {
3088 let store = Arc::clone(&self.saved_queries);
3089 let name_owned = name.to_string();
3090 let query = self
3091 .with_saved_query_store(|engine| store.get(engine, &name_owned))?
3092 .ok_or_else(|| {
3093 McpError::new(
3094 ErrorCode::TableNotFound,
3095 format!("No saved query named '{name_owned}'"),
3096 )
3097 })?;
3098 let sql = query.sql.clone();
3099 let body = self.with_engine(|engine| {
3100 let timer = crate::stats::StatsTimer::start();
3101 let rows = engine.execute_query_to_json(&sql)?;
3102 let elapsed = timer.elapsed_ms();
3103 let result_size = serde_json::to_string(&rows).map_or(0, |s| s.len() as u64);
3104 let stats = crate::stats::QueryStats {
3105 operation: "saved_query".into(),
3106 rows_returned: rows.len() as u64,
3107 rows_scanned: 0,
3108 elapsed_ms: elapsed,
3109 result_size_bytes: result_size,
3110 tables_touched: vec![],
3111 };
3112 Ok(json!({
3113 "name": query.name,
3114 "sql": Self::fmt_sql(&query.sql),
3115 "result": rows,
3116 "stats": stats.to_json(),
3117 }))
3118 })?;
3119 Ok(ResourceBody::Json(body))
3120 }
3121
3122 #[must_use]
3129 pub fn list_resource_uris(&self) -> Vec<String> {
3130 let mut uris = vec![
3131 "hyper://workspace".to_string(),
3132 "hyper://tables".to_string(),
3133 "hyper://readme".to_string(),
3134 ];
3135 if let Ok(tables) = self.with_engine(super::engine::Engine::describe_tables) {
3136 for table in tables {
3140 if let Some(name) = table.get("name").and_then(|v| v.as_str()) {
3141 uris.push(format!("hyper://tables/{name}/schema"));
3142 uris.push(format!("hyper://tables/{name}/sample"));
3143 uris.push(format!("hyper://tables/{name}/csv-sample"));
3144 }
3145 }
3146 }
3147 let store = Arc::clone(&self.saved_queries);
3148 if let Ok(saved) = self.with_saved_query_store(|engine| store.list(engine)) {
3149 for q in saved {
3150 uris.push(format!("hyper://queries/{}/definition", q.name));
3151 uris.push(format!("hyper://queries/{}/result", q.name));
3152 }
3153 }
3154 uris
3155 }
3156
3157 fn build_readme_body(&self) -> Result<ResourceBody, McpError> {
3165 let status = self.with_engine(super::engine::Engine::status)?;
3166 let tables = self
3167 .with_engine(super::engine::Engine::describe_tables)
3168 .unwrap_or_default();
3169
3170 let workspace_mode = status
3171 .get("workspace_mode")
3172 .and_then(|v| v.as_str())
3173 .unwrap_or("unknown");
3174 let workspace_path = status
3175 .get("workspace_path")
3176 .and_then(|v| v.as_str())
3177 .unwrap_or("");
3178 let read_only = status
3179 .get("read_only")
3180 .and_then(serde_json::Value::as_bool)
3181 .unwrap_or(false);
3182 let table_count = tables.len();
3183
3184 let mut md = String::new();
3185 md.push_str("# HyperDB workspace\n\n");
3186 let _ = writeln!(
3187 md,
3188 "- Mode: **{workspace_mode}**{}\n",
3189 if read_only { " (read-only)" } else { "" }
3190 );
3191 if !workspace_path.is_empty() {
3192 let _ = writeln!(md, "- Path: `{workspace_path}`\n");
3193 }
3194 let _ = write!(md, "- Tables: **{table_count}**\n\n");
3195
3196 if tables.is_empty() {
3197 md.push_str(
3198 "_No tables loaded yet._ Use the `load_file` or `load_data` tools to \
3199 ingest CSV / JSON / Parquet / Arrow IPC data; call `inspect_file` \
3200 first if you're unsure of the schema.\n",
3201 );
3202 } else {
3203 md.push_str("## Tables\n\n");
3204 md.push_str("| Table | Rows | Columns |\n");
3205 md.push_str("|---|---:|---|\n");
3206 for t in &tables {
3207 let name = t.get("name").and_then(|v| v.as_str()).unwrap_or("?");
3208 let rows = t
3209 .get("row_count")
3210 .and_then(serde_json::Value::as_i64)
3211 .unwrap_or(0);
3212 let cols: Vec<String> = t
3213 .get("columns")
3214 .and_then(|v| v.as_array())
3215 .map(|arr| {
3216 arr.iter()
3217 .filter_map(|c| {
3218 let n = c.get("name")?.as_str()?;
3219 let ty = c.get("type")?.as_str()?;
3220 Some(format!("`{n}` {ty}"))
3221 })
3222 .collect()
3223 })
3224 .unwrap_or_default();
3225 let _ = writeln!(md, "| `{name}` | {rows} | {} |\n", cols.join(", "));
3226 }
3227 md.push('\n');
3228 md.push_str("## Related resources\n\n");
3229 for t in &tables {
3230 if let Some(name) = t.get("name").and_then(|v| v.as_str()) {
3231 let _ = write!(md, "- `hyper://tables/{name}/schema` — JSON schema and row count\n\
3232 - `hyper://tables/{name}/sample` — first {TABLE_SAMPLE_ROWS} rows as JSON\n\
3233 - `hyper://tables/{name}/csv-sample` — first {TABLE_CSV_SAMPLE_ROWS} rows as CSV\n");
3234 }
3235 }
3236 md.push('\n');
3237 }
3238
3239 md.push_str(
3240 "## Tool hints\n\n\
3241 - `query(sql)` — read-only SQL (SELECT / WITH / EXPLAIN / SHOW / VALUES).\n\
3242 - `execute(sql)` — DDL/DML (disabled in read-only mode).\n\
3243 - `sample(table, n)` — configurable row sample; the fixed-size\n \
3244 `hyper://tables/{name}/sample` resource uses n=5.\n\
3245 - `inspect_file(path)` — dry-run schema inference before loading.\n\
3246 - `chart(sql, chart_type, ...)` — render a PNG/SVG from a query.\n\
3247 - `export(sql|table, path, format)` — write to CSV / Parquet / Arrow IPC / .hyper.\n",
3248 );
3249
3250 Ok(ResourceBody::Text {
3251 mime_type: "text/markdown".into(),
3252 content: md,
3253 })
3254 }
3255
3256 fn build_csv_sample_body(&self, table: &str) -> Result<ResourceBody, McpError> {
3260 let sample =
3261 self.with_engine(|engine| engine.sample_table(table, TABLE_CSV_SAMPLE_ROWS))?;
3262
3263 let header: Vec<String> = sample
3267 .get("schema")
3268 .and_then(|v| v.as_array())
3269 .map(|cols| {
3270 cols.iter()
3271 .filter_map(|c| c.get("name").and_then(|n| n.as_str()).map(String::from))
3272 .collect()
3273 })
3274 .filter(|v: &Vec<String>| !v.is_empty())
3275 .or_else(|| {
3276 sample
3277 .get("rows")
3278 .and_then(|v| v.as_array())
3279 .and_then(|rows| rows.first())
3280 .and_then(|r| r.as_object())
3281 .map(|o| o.keys().cloned().collect())
3282 })
3283 .unwrap_or_default();
3284
3285 let mut wtr = csv::Writer::from_writer(Vec::<u8>::new());
3286 if !header.is_empty() {
3287 wtr.write_record(&header).map_err(|e| {
3288 McpError::new(
3289 ErrorCode::InternalError,
3290 format!("Failed to write CSV header: {e}"),
3291 )
3292 })?;
3293 }
3294 if let Some(rows) = sample.get("rows").and_then(|v| v.as_array()) {
3295 for row in rows {
3296 let record: Vec<String> = header
3297 .iter()
3298 .map(|col| row.get(col).map(value_to_csv_cell).unwrap_or_default())
3299 .collect();
3300 wtr.write_record(&record).map_err(|e| {
3301 McpError::new(
3302 ErrorCode::InternalError,
3303 format!("Failed to write CSV row: {e}"),
3304 )
3305 })?;
3306 }
3307 }
3308 let bytes = wtr.into_inner().map_err(|e| {
3309 McpError::new(
3310 ErrorCode::InternalError,
3311 format!("Failed to finalize CSV: {e}"),
3312 )
3313 })?;
3314 let content = String::from_utf8(bytes).map_err(|e| {
3315 McpError::new(
3316 ErrorCode::InternalError,
3317 format!("CSV produced invalid UTF-8: {e}"),
3318 )
3319 })?;
3320
3321 Ok(ResourceBody::Text {
3322 mime_type: "text/csv".into(),
3323 content,
3324 })
3325 }
3326
3327 fn build_analyze_context(&self, table: &str) -> String {
3330 match self.with_engine(|engine| engine.sample_table(table, 10)) {
3331 Ok(sample) => format!(
3332 "Schema and sample:\n```json\n{}\n```",
3333 serde_json::to_string_pretty(&sample).unwrap_or_else(|_| sample.to_string())
3334 ),
3335 Err(e) => format!("(Could not load table context: {e})"),
3336 }
3337 }
3338
3339 fn build_brief_context(&self, table: &str) -> String {
3341 match self.with_engine(|engine| engine.sample_table(table, 5)) {
3342 Ok(sample) => format!(
3343 "```json\n{}\n```",
3344 serde_json::to_string_pretty(&sample).unwrap_or_else(|_| sample.to_string())
3345 ),
3346 Err(e) => format!("(Could not load table context: {e})"),
3347 }
3348 }
3349}
3350
3351#[tool_handler]
3354#[prompt_handler]
3355impl ServerHandler for HyperMcpServer {
3356 fn get_info(&self) -> ServerInfo {
3357 let sql_dialect = "\n\
3358\n\
3359SQL DIALECT — Salesforce Data Cloud SQL (PostgreSQL-compatible with extensions).\n\
3360Key differences from standard PostgreSQL an LLM should know:\n\
3361\n\
3362TYPES\n\
3363- Supported: SMALLINT, INTEGER/INT, BIGINT, REAL/FLOAT4, DOUBLE PRECISION/FLOAT8,\n\
3364 NUMERIC(p,s)/DECIMAL(p,s), BOOLEAN, TEXT, CHAR(n), VARCHAR(n), BYTES,\n\
3365 DATE, TIME, TIMESTAMP, TIMESTAMPTZ, INTERVAL, and arrays of any atomic type\n\
3366- NUMERIC precision > 18 requires .hyper file format version 3 (default in this MCP)\n\
3367- No SERIAL / BIGSERIAL / UUID / JSON / JSONB / geometry types\n\
3368\n\
3369SELECT / QUERY\n\
3370- LIMIT / OFFSET work as in PostgreSQL; TOP N is also accepted\n\
3371- LATERAL is optional: subqueries in FROM always see preceding FROM items implicitly\n\
3372- DISTINCT ON (expr, ...) is supported\n\
3373- FROM clause is optional (can evaluate expressions without a table)\n\
3374- Function calls may appear directly in the FROM list\n\
3375- information_schema and pg_catalog do NOT exist; use the describe/sample tools\n\
3376\n\
3377GROUP BY / AGGREGATION\n\
3378- GROUPING SETS, ROLLUP, CUBE all supported\n\
3379- GROUP BY DISTINCT removes duplicate grouping sets before processing\n\
3380- FILTER (WHERE ...) clause supported on aggregate calls\n\
3381- Ordered-set aggregates: MODE(), PERCENTILE_CONT(), PERCENTILE_DISC() with WITHIN GROUP (ORDER BY ...)\n\
3382- APPROX_COUNT_DISTINCT() for fast approximate cardinality\n\
3383- GROUPING() function identifies which columns are aggregated in GROUPING SETS\n\
3384\n\
3385WINDOW FUNCTIONS\n\
3386- Standard: row_number, rank, dense_rank, percent_rank, cume_dist, ntile, lag, lead,\n\
3387 first_value, last_value, nth_value\n\
3388- Hyper extension: modified_rank() — like rank() but assigns the LOWEST rank on ties\n\
3389- IGNORE NULLS / RESPECT NULLS supported on last_value only\n\
3390- nth_value supports FROM FIRST / FROM LAST\n\
3391- Frame modes: ROWS, RANGE, GROUPS; EXCLUDE CURRENT ROW / GROUP / TIES / NO OTHERS\n\
3392- Window-specific functions do NOT support DISTINCT or ORDER BY in their argument list\n\
3393\n\
3394SET-RETURNING FUNCTIONS (usable in FROM)\n\
3395- unnest(array) — expands an array to rows; supports WITH ORDINALITY\n\
3396- generate_series(start, stop [, step]) — numeric and datetime variants\n\
3397- external(path, format => '...') — reads Parquet, CSV, Iceberg etc. directly from files\n\
3398\n\
3399SET OPERATORS\n\
3400- UNION, INTERSECT, EXCEPT all supported; INTERSECT binds tighter than UNION/EXCEPT\n\
3401- ORDER BY and LIMIT/OFFSET can appear on parenthesized sub-expressions or the final result\n\
3402\n\
3403CTEs\n\
3404- WITH and WITH RECURSIVE both supported\n\
3405- CTEs evaluate once per query execution even if referenced multiple times\n\
3406\n\
3407IDENTIFIERS\n\
3408- Unquoted identifiers are folded to lowercase; double-quote to preserve case or use special chars\n\
3409- Quote names containing uppercase letters, digits at the start, or special characters\n\
3410\n\
3411NOT AVAILABLE IN HYPER (Data 360 / Data Cloud-only features)\n\
3412- AI functions: AI_CLASSIFY, AI_SENTIMENT, and other Data Cloud AI scalar functions\n\
3413- Data Cloud federation / streaming-specific functions\n\
3414\n\
3415Full SQL reference: https://developer.salesforce.com/docs/data/data-cloud-query-guide/references/dc-sql-reference";
3416
3417 let header = if self.read_only {
3418 "HyperDB MCP (read-only): SQL analytics for LLM workflows. Query existing tables, \
3419 sample data, export results. Mutating operations are disabled. \
3420 Call get_readme for a concise tool index, parameter rules, and usage examples."
3421 } else {
3422 "HyperDB MCP: instant SQL analytics for LLM workflows. Load data (CSV, JSON, Parquet, \
3423 Arrow IPC, Apache Iceberg), query with SQL, export results (Parquet, Iceberg, Arrow IPC, \
3424 CSV, Hyper). Use query for SELECT and execute for DDL/DML. \
3425 Call get_readme for a concise tool index, parameter rules, and usage examples."
3426 };
3427 let instructions = format!("{header}{sql_dialect}");
3428 let mut server_info = Implementation::default();
3429 server_info.name = "HyperDB".into();
3430 server_info.title = Some("HyperDB — Hyper SQL Analytics".into());
3431 server_info.version = env!("CARGO_PKG_VERSION").into();
3432 server_info.description = Some(
3433 "MCP server for Tableau Hyper: instant SQL analytics over \
3434 CSV, JSON, Parquet, Arrow IPC, and Apache Iceberg with schema inference, \
3435 partial schema overrides, full-file numeric widening, and \
3436 dry-run file inspection. SQL dialect is PostgreSQL-compatible with \
3437 extensions (Salesforce Data Cloud SQL). Full SQL reference: \
3438 https://developer.salesforce.com/docs/data/data-cloud-query-guide/references/dc-sql-reference/data-cloud-sql-context.html"
3439 .into(),
3440 );
3441
3442 let mut info = ServerInfo::default();
3443 info.instructions = Some(instructions);
3444 info.server_info = server_info;
3445 info.capabilities = ServerCapabilities::builder()
3446 .enable_tools()
3447 .enable_prompts()
3448 .enable_resources()
3449 .enable_resources_subscribe()
3454 .enable_resources_list_changed()
3455 .build();
3456 info
3457 }
3458
3459 async fn subscribe(
3468 &self,
3469 request: SubscribeRequestParams,
3470 context: RequestContext<RoleServer>,
3471 ) -> Result<(), rmcp::ErrorData> {
3472 self.subscriptions.subscribe(&request.uri, context.peer);
3473 Ok(())
3474 }
3475
3476 async fn unsubscribe(
3481 &self,
3482 request: UnsubscribeRequestParams,
3483 context: RequestContext<RoleServer>,
3484 ) -> Result<(), rmcp::ErrorData> {
3485 self.subscriptions.unsubscribe(&request.uri, &context.peer);
3486 Ok(())
3487 }
3488
3489 async fn list_resources(
3495 &self,
3496 _request: Option<PaginatedRequestParams>,
3497 _context: RequestContext<RoleServer>,
3498 ) -> Result<ListResourcesResult, rmcp::ErrorData> {
3499 let mut resources = vec![
3500 RawResource {
3501 uri: "hyper://workspace".into(),
3502 name: "Workspace Info".into(),
3503 title: Some("Hyper Workspace".into()),
3504 description: Some("Workspace mode, table count, total rows, disk usage".into()),
3505 mime_type: Some("application/json".into()),
3506 size: None,
3507 icons: None,
3508 meta: None,
3509 }
3510 .no_annotation(),
3511 RawResource {
3512 uri: "hyper://tables".into(),
3513 name: "All Tables".into(),
3514 title: Some("All Tables".into()),
3515 description: Some("List of all tables with column schemas and row counts".into()),
3516 mime_type: Some("application/json".into()),
3517 size: None,
3518 icons: None,
3519 meta: None,
3520 }
3521 .no_annotation(),
3522 RawResource {
3523 uri: "hyper://readme".into(),
3524 name: "Workspace Readme".into(),
3525 title: Some("HyperDB workspace readme".into()),
3526 description: Some(
3527 "Markdown overview of the workspace: tables, row counts, related \
3528 resources, and tool hints for LLMs orienting themselves."
3529 .into(),
3530 ),
3531 mime_type: Some("text/markdown".into()),
3532 size: None,
3533 icons: None,
3534 meta: None,
3535 }
3536 .no_annotation(),
3537 ];
3538
3539 if let Ok(tables) = self.with_engine(super::engine::Engine::describe_tables) {
3540 for table in tables {
3544 if let Some(name) = table.get("name").and_then(|v| v.as_str()) {
3545 let row_count = table
3546 .get("row_count")
3547 .and_then(serde_json::Value::as_i64)
3548 .unwrap_or(0);
3549 resources.push(
3550 RawResource {
3551 uri: format!("hyper://tables/{name}/schema"),
3552 name: format!("Schema of {name}"),
3553 title: Some(format!("{name} schema")),
3554 description: Some(format!(
3555 "Column schema and row count ({row_count} rows) for table '{name}'"
3556 )),
3557 mime_type: Some("application/json".into()),
3558 size: None,
3559 icons: None,
3560 meta: None,
3561 }
3562 .no_annotation(),
3563 );
3564 resources.push(
3565 RawResource {
3566 uri: format!("hyper://tables/{name}/sample"),
3567 name: format!("Sample of {name}"),
3568 title: Some(format!("{name} sample (JSON)")),
3569 description: Some(format!(
3570 "First {TABLE_SAMPLE_ROWS} rows of '{name}' as JSON, with schema"
3571 )),
3572 mime_type: Some("application/json".into()),
3573 size: None,
3574 icons: None,
3575 meta: None,
3576 }
3577 .no_annotation(),
3578 );
3579 resources.push(
3580 RawResource {
3581 uri: format!("hyper://tables/{name}/csv-sample"),
3582 name: format!("CSV sample of {name}"),
3583 title: Some(format!("{name} sample (CSV)")),
3584 description: Some(format!(
3585 "First {TABLE_CSV_SAMPLE_ROWS} rows of '{name}' as CSV"
3586 )),
3587 mime_type: Some("text/csv".into()),
3588 size: None,
3589 icons: None,
3590 meta: None,
3591 }
3592 .no_annotation(),
3593 );
3594 }
3595 }
3596 }
3597
3598 let store = Arc::clone(&self.saved_queries);
3599 if let Ok(saved) = self.with_saved_query_store(|engine| store.list(engine)) {
3600 for q in saved {
3601 let desc = q
3602 .description
3603 .clone()
3604 .unwrap_or_else(|| format!("Saved read-only SQL query '{}'", q.name));
3605 resources.push(
3606 RawResource {
3607 uri: format!("hyper://queries/{}/definition", q.name),
3608 name: format!("Query: {}", q.name),
3609 title: Some(format!("{} (definition)", q.name)),
3610 description: Some(format!("SQL + metadata for saved query '{}'", q.name)),
3611 mime_type: Some("application/json".into()),
3612 size: None,
3613 icons: None,
3614 meta: None,
3615 }
3616 .no_annotation(),
3617 );
3618 resources.push(
3619 RawResource {
3620 uri: format!("hyper://queries/{}/result", q.name),
3621 name: format!("Result: {}", q.name),
3622 title: Some(format!("{} (result)", q.name)),
3623 description: Some(format!("{desc} — re-runs on every read")),
3624 mime_type: Some("application/json".into()),
3625 size: None,
3626 icons: None,
3627 meta: None,
3628 }
3629 .no_annotation(),
3630 );
3631 }
3632 }
3633
3634 Ok(ListResourcesResult {
3635 resources,
3636 next_cursor: None,
3637 meta: None,
3638 })
3639 }
3640
3641 async fn list_resource_templates(
3644 &self,
3645 _request: Option<PaginatedRequestParams>,
3646 _context: RequestContext<RoleServer>,
3647 ) -> Result<ListResourceTemplatesResult, rmcp::ErrorData> {
3648 let templates = vec![
3649 RawResourceTemplate {
3650 uri_template: "hyper://tables/{name}/schema".into(),
3651 name: "Table Schema".into(),
3652 title: Some("Table Schema".into()),
3653 description: Some(
3654 "Column schema, types, nullability, and row count for a named table".into(),
3655 ),
3656 mime_type: Some("application/json".into()),
3657 icons: None,
3658 }
3659 .no_annotation(),
3660 RawResourceTemplate {
3661 uri_template: "hyper://tables/{name}/sample".into(),
3662 name: "Table Sample (JSON)".into(),
3663 title: Some("Table Sample".into()),
3664 description: Some(
3665 "First few rows of a named table as JSON, with schema. For a \
3666 configurable row count use the `sample` tool instead."
3667 .into(),
3668 ),
3669 mime_type: Some("application/json".into()),
3670 icons: None,
3671 }
3672 .no_annotation(),
3673 RawResourceTemplate {
3674 uri_template: "hyper://tables/{name}/csv-sample".into(),
3675 name: "Table Sample (CSV)".into(),
3676 title: Some("Table Sample (CSV)".into()),
3677 description: Some(
3678 "First few rows of a named table as CSV, header-first, for \
3679 spreadsheet and Pandas consumers."
3680 .into(),
3681 ),
3682 mime_type: Some("text/csv".into()),
3683 icons: None,
3684 }
3685 .no_annotation(),
3686 RawResourceTemplate {
3687 uri_template: "hyper://queries/{name}/definition".into(),
3688 name: "Saved Query Definition".into(),
3689 title: Some("Saved Query Definition".into()),
3690 description: Some(
3691 "Stored SQL plus metadata (description, created_at) for a saved \
3692 query registered via the `save_query` tool."
3693 .into(),
3694 ),
3695 mime_type: Some("application/json".into()),
3696 icons: None,
3697 }
3698 .no_annotation(),
3699 RawResourceTemplate {
3700 uri_template: "hyper://queries/{name}/result".into(),
3701 name: "Saved Query Result".into(),
3702 title: Some("Saved Query Result".into()),
3703 description: Some(
3704 "Live result of a saved query. The stored SQL re-runs on every \
3705 resource read — no caching, always fresh."
3706 .into(),
3707 ),
3708 mime_type: Some("application/json".into()),
3709 icons: None,
3710 }
3711 .no_annotation(),
3712 ];
3713 Ok(ListResourceTemplatesResult {
3714 resource_templates: templates,
3715 next_cursor: None,
3716 meta: None,
3717 })
3718 }
3719
3720 async fn read_resource(
3725 &self,
3726 request: ReadResourceRequestParams,
3727 _context: RequestContext<RoleServer>,
3728 ) -> Result<ReadResourceResult, rmcp::ErrorData> {
3729 let uri = &request.uri;
3730 let (mime_type, text) = match self.resource_body_for_uri(uri) {
3731 Ok(Some(body)) => (body.mime_type().to_string(), body.to_text()),
3732 Ok(None) => {
3733 return Err(rmcp::ErrorData::invalid_params(
3734 format!("Unknown resource URI: {uri}"),
3735 None,
3736 ));
3737 }
3738 Err(e) => {
3739 let err_val = serde_json::to_value(&e).unwrap_or(Value::String(e.to_string()));
3742 let text =
3743 serde_json::to_string_pretty(&json!({ "error": err_val })).unwrap_or_default();
3744 ("application/json".into(), text)
3745 }
3746 };
3747
3748 Ok(ReadResourceResult::new(vec![
3749 ResourceContents::TextResourceContents {
3750 uri: uri.clone(),
3751 mime_type: Some(mime_type),
3752 text,
3753 meta: None,
3754 },
3755 ]))
3756 }
3757}
3758
3759fn is_structural_sql(sql: &str) -> bool {
3769 let trimmed = sql.trim_start();
3770 let first: String = trimmed
3771 .chars()
3772 .take_while(|c| c.is_alphabetic())
3773 .flat_map(char::to_uppercase)
3774 .collect();
3775 matches!(
3776 first.as_str(),
3777 "CREATE" | "DROP" | "ALTER" | "TRUNCATE" | "RENAME"
3778 )
3779}
3780
3781fn value_to_csv_cell(v: &Value) -> String {
3787 match v {
3788 Value::Null => String::new(),
3789 Value::Bool(b) => b.to_string(),
3790 Value::Number(n) => n.to_string(),
3791 Value::String(s) => s.clone(),
3792 _ => v.to_string(),
3793 }
3794}
3795
3796fn detect_format(data: &str) -> String {
3799 let trimmed = data.trim_start();
3800 if trimmed.starts_with('[') || trimmed.starts_with('{') {
3801 "json".into()
3802 } else {
3803 "csv".into()
3804 }
3805}
3806
3807fn rand_suffix() -> String {
3811 use std::time::{SystemTime, UNIX_EPOCH};
3812 let t = SystemTime::now()
3813 .duration_since(UNIX_EPOCH)
3814 .unwrap_or_default();
3815 format!("{}", t.as_nanos() % 1_000_000_000)
3816}
3817
3818fn qualified_name(engine: &Engine, db: Option<&str>, table: &str) -> String {
3829 let alias = db.map_or_else(|| engine.primary_db_name(), str::to_string);
3830 let escaped_alias = alias.replace('"', "\"\"");
3831 let escaped_table = table.replace('"', "\"\"");
3832 format!("\"{escaped_alias}\".\"public\".\"{escaped_table}\"")
3833}
3834
3835fn target_exists(engine: &Engine, db: Option<&str>, table: &str) -> Result<bool, McpError> {
3840 let sql = format!(
3841 "SELECT 1 FROM {} LIMIT 0",
3842 qualified_name(engine, db, table)
3843 );
3844 match engine.execute_query_to_json(&sql) {
3845 Ok(_) => Ok(true),
3846 Err(e) => {
3847 let m = e.message.to_lowercase();
3848 let missing = m.contains("does not exist")
3849 || m.contains("undefined table")
3850 || e.message.contains("42P01");
3851 if missing {
3852 Ok(false)
3853 } else {
3854 Err(e)
3855 }
3856 }
3857 }
3858}
3859
3860fn count_rows(engine: &Engine, db: Option<&str>, table: &str) -> i64 {
3865 let sql = format!(
3866 "SELECT COUNT(*) AS cnt FROM {}",
3867 qualified_name(engine, db, table)
3868 );
3869 engine
3870 .execute_query_to_json(&sql)
3871 .ok()
3872 .and_then(|rows| {
3873 rows.first()
3874 .and_then(|r| r.get("cnt").and_then(serde_json::Value::as_i64))
3875 })
3876 .unwrap_or(0)
3877}
3878
3879fn probe_table_count(engine: &Engine, alias: &str) -> Value {
3883 let escaped_alias = alias.replace('"', "\"\"");
3884 let sql = format!(
3885 "SELECT COUNT(*) AS cnt FROM \"{escaped_alias}\".pg_catalog.pg_tables WHERE schemaname = 'public'"
3886 );
3887 match engine.execute_query_to_json(&sql) {
3888 Ok(rows) => rows
3889 .first()
3890 .and_then(|r| r.get("cnt").and_then(serde_json::Value::as_i64))
3891 .map_or(Value::Null, |n| json!(n)),
3892 Err(_) => Value::Null,
3893 }
3894}
3895
3896fn prepare_temp_attachments(
3900 specs: &[AttachSpec],
3901 read_only: bool,
3902) -> Result<Vec<AttachRequest>, McpError> {
3903 let mut out = Vec::with_capacity(specs.len());
3904 for spec in specs {
3905 let writable = spec.writable.unwrap_or(false);
3906 if writable && read_only {
3907 return Err(McpError::new(
3908 ErrorCode::ReadOnlyViolation,
3909 format!(
3910 "temp_attach for alias '{}' requested writable:true but the server is --read-only",
3911 spec.alias
3912 ),
3913 ));
3914 }
3915 let on_missing = attach::OnMissing::parse(spec.on_missing.as_deref())?;
3916 if on_missing == attach::OnMissing::Create && !writable {
3917 return Err(McpError::new(
3918 ErrorCode::InvalidArgument,
3919 format!(
3920 "temp_attach alias '{}' has on_missing='create' but writable is not true — \
3921 an empty .hyper file that cannot be written to cannot be populated.",
3922 spec.alias
3923 ),
3924 ));
3925 }
3926 let source = match spec.kind.as_str() {
3927 "local_file" => {
3928 let Some(raw) = spec.path.as_deref() else {
3929 return Err(McpError::new(
3930 ErrorCode::InvalidArgument,
3931 format!("temp_attach alias '{}' requires a 'path'", spec.alias),
3932 ));
3933 };
3934 let resolved = match on_missing {
3935 attach::OnMissing::Error => attach::validate_local_path(raw)?,
3936 attach::OnMissing::Create => attach::validate_local_path_for_create(raw)?,
3937 };
3938 AttachSource::LocalFile { path: resolved }
3939 }
3940 other => {
3941 return Err(McpError::new(
3942 ErrorCode::InvalidArgument,
3943 format!(
3944 "Unsupported temp_attach kind '{other}' for alias '{}'. Only 'local_file' is supported today.",
3945 spec.alias
3946 ),
3947 ));
3948 }
3949 };
3950 attach::validate_alias(&spec.alias)?;
3951 out.push(AttachRequest {
3952 alias: spec.alias.clone(),
3953 source,
3954 writable,
3955 on_missing,
3956 });
3957 }
3958 Ok(out)
3959}
3960
3961fn perform_copy(
3966 engine: &Engine,
3967 mode: &str,
3968 target_db: Option<&str>,
3969 target_table: &str,
3970 sql_body: &str,
3971) -> Result<Value, McpError> {
3972 let qualified = qualified_name(engine, target_db, target_table);
3973 let exists = target_exists(engine, target_db, target_table)?;
3974 let timer = crate::stats::StatsTimer::start();
3975
3976 match mode {
3977 "create" => {
3978 if exists {
3979 return Err(McpError::new(
3980 ErrorCode::InvalidArgument,
3981 format!(
3982 "Target '{target_table}' already exists. Use mode='append' to add rows or mode='replace' to drop and recreate."
3983 ),
3984 ));
3985 }
3986 engine.execute_command(&format!("CREATE TABLE {qualified} AS {sql_body}"))?;
3987 }
3988 "append" => {
3989 if !exists {
3990 return Err(McpError::new(
3991 ErrorCode::InvalidArgument,
3992 format!(
3993 "Target '{target_table}' does not exist. Use mode='create' to create it from the query or mode='replace' to drop and recreate."
3994 ),
3995 ));
3996 }
3997 engine.execute_command(&format!("INSERT INTO {qualified} {sql_body}"))?;
3998 }
3999 "replace" => {
4000 engine.execute_command(&format!("DROP TABLE IF EXISTS {qualified}"))?;
4009 engine.execute_command(&format!("CREATE TABLE {qualified} AS {sql_body}"))?;
4010 }
4011 other => {
4012 return Err(McpError::new(
4013 ErrorCode::InvalidArgument,
4014 format!("copy_query mode '{other}' is not supported"),
4015 ));
4016 }
4017 }
4018
4019 let elapsed_ms = timer.elapsed_ms();
4020 let row_count = count_rows(engine, target_db, target_table);
4021 Ok(json!({
4022 "target_table": target_table,
4023 "target_database": target_db.unwrap_or(LOCAL_ALIAS),
4024 "mode": mode,
4025 "row_count": row_count,
4026 "stats": { "operation": "copy_query", "elapsed_ms": elapsed_ms },
4027 }))
4028}