1use crate::attach::{self, AttachRegistry, AttachRequest, AttachSource, LOCAL_ALIAS};
15use crate::chart::{render_chart, ChartFormat, ChartOptions, ChartType};
16use crate::engine::{classify_statement, is_read_only_sql, Engine, StatementKind};
17use crate::error::{ErrorCode, McpError};
18use crate::export::{export_to_file, ExportOptions};
19use crate::ingest::{
20 detect_file_format, ingest_csv, ingest_csv_file, ingest_csv_file_async, ingest_json,
21 ingest_json_file, ingest_json_file_async, InferredFileFormat, IngestOptions,
22};
23use crate::ingest_arrow::{
24 ingest_arrow_ipc_file, ingest_arrow_ipc_file_async, ingest_parquet_file,
25 ingest_parquet_file_async,
26};
27use crate::saved_queries::{build_store, SavedQuery, SavedQueryStore};
28use crate::subscriptions::{
29 uris_for_table_change, uris_for_workspace_change, SubscriptionRegistry,
30};
31use base64::Engine as _;
32use rmcp::handler::server::router::prompt::PromptRouter;
33use rmcp::handler::server::router::tool::ToolRouter;
34use rmcp::handler::server::wrapper::Parameters;
35use rmcp::model::{
36 AnnotateAble, CallToolResult, Content, GetPromptRequestParams, GetPromptResult, Implementation,
37 InitializeRequestParams, InitializeResult, ListPromptsResult, ListResourceTemplatesResult,
38 ListResourcesResult, PaginatedRequestParams, PromptMessage, PromptMessageRole, RawResource,
39 RawResourceTemplate, ReadResourceRequestParams, ReadResourceResult, ResourceContents,
40 ServerCapabilities, ServerInfo, SubscribeRequestParams, UnsubscribeRequestParams,
41};
42use rmcp::service::RequestContext;
43use rmcp::{
44 prompt, prompt_handler, prompt_router, tool, tool_handler, tool_router, RoleServer,
45 ServerHandler,
46};
47use schemars::JsonSchema;
48use serde::Deserialize;
49use serde_json::{json, Value};
50use sqlformat::{FormatOptions, Indent, QueryParams as SqlQueryParams};
51use std::fmt::Write as _;
52use std::sync::{Arc, Mutex};
53
54#[expect(
55 unused_imports,
56 reason = "imported for use in doc comments that reference the type path"
57)]
58use rmcp::model::RawTextContent;
59
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 pub database: Option<String>,
161 pub persist: Option<bool>,
165}
166
167#[derive(Debug, Deserialize, JsonSchema)]
169pub struct LoadFileParams {
170 pub table: String,
172 pub path: String,
174 pub mode: Option<String>,
179 pub schema: Option<Value>,
186 pub json_extract_path: Option<String>,
192 pub merge_key: Option<MergeKey>,
197 pub database: Option<String>,
201 pub persist: Option<bool>,
204}
205
206#[derive(Debug, JsonSchema)]
216#[schemars(
217 title = "MergeKey",
218 description = "Either a single column name (string) or a list of column names (array of strings)",
219 untagged
220)]
221pub enum MergeKey {
222 Single(String),
223 Multi(Vec<String>),
224}
225
226impl<'de> Deserialize<'de> for MergeKey {
227 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
228 where
229 D: serde::Deserializer<'de>,
230 {
231 use serde::de::Error;
232 let v = serde_json::Value::deserialize(deserializer)?;
233 match v {
234 serde_json::Value::String(s) => Ok(Self::Single(s)),
235 serde_json::Value::Array(arr) => {
236 let mut names = Vec::with_capacity(arr.len());
237 for (i, item) in arr.into_iter().enumerate() {
238 match item {
239 serde_json::Value::String(s) => names.push(s),
240 other => {
241 return Err(D::Error::custom(format!(
242 "merge_key array element [{i}] must be a string \
243 (column name); got {other}"
244 )));
245 }
246 }
247 }
248 Ok(Self::Multi(names))
249 }
250 other => Err(D::Error::custom(format!(
251 "merge_key must be a column name (string) or list of column names \
252 (array of strings); got {other}"
253 ))),
254 }
255 }
256}
257
258impl MergeKey {
259 pub fn into_vec(self) -> Option<Vec<String>> {
263 let v = match self {
264 Self::Single(s) => vec![s],
265 Self::Multi(v) => v,
266 };
267 if v.is_empty() || v.iter().any(String::is_empty) {
268 None
269 } else {
270 Some(v)
271 }
272 }
273}
274
275#[derive(Debug, Deserialize, JsonSchema)]
279pub struct LoadFilesEntry {
280 pub table: String,
282 pub path: String,
284 pub mode: Option<String>,
287 pub schema: Option<Value>,
289 pub json_extract_path: Option<String>,
291 pub merge_key: Option<MergeKey>,
294}
295
296#[derive(Debug, Deserialize, JsonSchema)]
298pub struct LoadFilesParams {
299 pub files: Vec<LoadFilesEntry>,
303 pub concurrency: Option<u32>,
309 pub database: Option<String>,
315 pub persist: Option<bool>,
318}
319
320fn validate_merge_args(
330 mode: &str,
331 merge_key: Option<MergeKey>,
332) -> Result<Option<Vec<String>>, McpError> {
333 match (mode, merge_key) {
334 ("merge", None) => Err(McpError::new(
335 ErrorCode::InvalidArgument,
336 "mode=merge requires merge_key (a column name or list of column names)",
337 )),
338 ("merge", Some(mk)) => mk.into_vec().map(Some).ok_or_else(|| {
339 McpError::new(
340 ErrorCode::InvalidArgument,
341 "merge_key must be a non-empty list of non-empty column names",
342 )
343 }),
344 (_, Some(_)) => Err(McpError::new(
345 ErrorCode::InvalidArgument,
346 "merge_key is only valid with mode=merge",
347 )),
348 (_, None) => Ok(None),
349 }
350}
351
352#[derive(Debug, Deserialize, JsonSchema)]
358pub struct LoadIcebergParams {
359 pub table: String,
361 pub path: String,
364 pub mode: Option<String>,
366 pub metadata_filename: Option<String>,
369 pub version_as_of: Option<i64>,
371}
372
373#[derive(Debug, Deserialize, JsonSchema)]
375pub struct QueryParams {
376 pub sql: String,
378 pub database: Option<String>,
382}
383
384#[derive(Debug, Deserialize, JsonSchema)]
386pub struct ExecuteParams {
387 pub sql: Vec<String>,
405 pub database: Option<String>,
409}
410
411#[derive(Debug, Deserialize, JsonSchema)]
413pub struct SampleParams {
414 pub table: String,
416 pub n: Option<u64>,
418 pub database: Option<String>,
421}
422
423#[derive(Debug, Default, Deserialize, JsonSchema)]
427pub struct DescribeParams {
428 pub table: Option<String>,
431 pub database: Option<String>,
435}
436
437#[derive(Debug, Deserialize, JsonSchema)]
439pub struct ChartParams {
440 pub sql: String,
442 pub chart_type: String,
444 pub x: Option<String>,
446 pub y: Option<String>,
448 pub series: Option<String>,
450 pub title: Option<String>,
452 pub format: Option<String>,
454 pub width: Option<u32>,
456 pub height: Option<u32>,
458 pub bins: Option<u32>,
460 pub x_as_category: Option<bool>,
466 pub x_range: Option<[f64; 2]>,
471 pub y_range: Option<[f64; 2]>,
474 pub color_map: Option<std::collections::HashMap<String, String>>,
479 pub label_points: Option<bool>,
484 pub output_path: Option<String>,
490 pub inline: Option<bool>,
496 pub overwrite: Option<bool>,
500 pub database: Option<String>,
504}
505
506#[derive(Debug, Deserialize, JsonSchema)]
508pub struct WatchDirectoryParams {
509 pub path: String,
511 pub table: String,
513 #[serde(default)]
516 pub max_concurrent: Option<u32>,
517 pub database: Option<String>,
526 pub persist: Option<bool>,
529}
530
531#[derive(Debug, Deserialize, JsonSchema)]
533pub struct UnwatchDirectoryParams {
534 pub path: String,
536}
537
538#[derive(Debug, Deserialize, JsonSchema)]
548pub struct InspectFileParams {
549 pub path: String,
552 pub sample_rows: Option<u32>,
556 pub json_extract_path: Option<String>,
560}
561
562#[derive(Debug, Deserialize, JsonSchema)]
564pub struct ExportParams {
565 pub sql: Option<String>,
567 pub table: Option<String>,
569 pub path: String,
571 pub format: String,
576 pub overwrite: Option<bool>,
580 pub format_options: Option<Value>,
598 pub database: Option<String>,
604}
605
606#[derive(Debug, Deserialize, JsonSchema)]
620pub struct SaveQueryParams {
621 pub name: String,
624 pub sql: String,
628 pub description: Option<String>,
630}
631
632#[derive(Debug, Deserialize, JsonSchema)]
634pub struct DeleteQueryParams {
635 pub name: String,
638}
639
640#[derive(Debug, Deserialize, JsonSchema, Clone)]
645pub struct AttachSpec {
646 pub alias: String,
650 pub kind: String,
654 pub path: Option<String>,
657 pub writable: Option<bool>,
661 pub on_missing: Option<String>,
667}
668
669#[derive(Debug, Deserialize, JsonSchema)]
673pub struct AttachDatabaseParams {
674 pub alias: String,
678 pub kind: String,
680 pub path: Option<String>,
684 pub writable: Option<bool>,
688 pub on_missing: Option<String>,
699}
700
701#[derive(Debug, Deserialize, JsonSchema)]
703pub struct DetachDatabaseParams {
704 pub alias: String,
706}
707
708#[derive(Debug, Deserialize, JsonSchema)]
716pub struct CopyQueryParams {
717 pub sql: String,
722 pub target_table: String,
725 pub mode: String,
733 pub target_database: Option<String>,
737 pub temp_attach: Option<Vec<AttachSpec>>,
741}
742
743#[derive(Debug, Deserialize, JsonSchema)]
751pub struct SetTableMetadataParams {
752 pub table: String,
756 pub source_url: Option<String>,
758 pub source_description: Option<String>,
761 pub purpose: Option<String>,
764 pub license: Option<String>,
766 pub notes: Option<String>,
768 pub database: Option<String>,
776}
777
778#[derive(Debug, Deserialize, JsonSchema)]
782pub struct AnalyzeTableArgs {
783 pub table: String,
785}
786
787#[derive(Debug, Deserialize, JsonSchema)]
789pub struct CompareTablesArgs {
790 pub table_a: String,
792 pub table_b: String,
794}
795
796#[derive(Debug, Deserialize, JsonSchema)]
798pub struct DataQualityArgs {
799 pub table: String,
801}
802
803#[derive(Debug, Deserialize, JsonSchema)]
805pub struct SuggestQueriesArgs {
806 pub table: String,
808 pub goal: Option<String>,
810}
811
812pub struct HyperMcpServer {
821 engine: Arc<Mutex<Option<Engine>>>,
822 catalog_ready: Arc<Mutex<bool>>,
828 watchers: Arc<crate::watcher::WatcherRegistry>,
829 saved_queries: Arc<dyn SavedQueryStore>,
830 subscriptions: Arc<SubscriptionRegistry>,
831 attachments: Arc<AttachRegistry>,
837 workspace_path: Option<String>,
841 read_only: bool,
842 no_daemon: bool,
844 last_heartbeat: std::sync::Mutex<std::time::Instant>,
846 client_name: std::sync::Mutex<Option<String>>,
850 #[expect(dead_code, reason = "constructed for rmcp 1.x macro-based dispatch")]
855 tool_router: ToolRouter<Self>,
856 #[expect(dead_code, reason = "constructed for rmcp 1.x macro-based dispatch")]
857 prompt_router: PromptRouter<Self>,
858}
859
860impl std::fmt::Debug for HyperMcpServer {
861 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
862 f.debug_struct("HyperMcpServer")
863 .field("persistent_path", &self.workspace_path)
864 .field("read_only", &self.read_only)
865 .field("no_daemon", &self.no_daemon)
866 .finish_non_exhaustive()
867 }
868}
869
870impl HyperMcpServer {
871 pub fn new(persistent_path: Option<String>, read_only: bool) -> Self {
891 Self::with_options(persistent_path, read_only, false)
892 }
893
894 pub fn with_no_daemon(
896 persistent_path: Option<String>,
897 read_only: bool,
898 no_daemon: bool,
899 ) -> Self {
900 Self::with_options(persistent_path, read_only, no_daemon)
901 }
902
903 fn with_options(persistent_path: Option<String>, read_only: bool, no_daemon: bool) -> Self {
904 let saved_queries: Arc<dyn SavedQueryStore> = build_store(persistent_path.as_deref());
907 Self {
908 engine: Arc::new(Mutex::new(None)),
909 catalog_ready: Arc::new(Mutex::new(false)),
910 watchers: Arc::new(crate::watcher::WatcherRegistry::new()),
911 saved_queries,
912 subscriptions: Arc::new(SubscriptionRegistry::new()),
913 attachments: Arc::new(AttachRegistry::new()),
918 workspace_path: persistent_path,
919 read_only,
920 no_daemon,
921 last_heartbeat: std::sync::Mutex::new(std::time::Instant::now()),
922 client_name: std::sync::Mutex::new(None),
923 tool_router: Self::tool_router(),
924 prompt_router: Self::prompt_router(),
925 }
926 }
927
928 #[must_use]
932 pub fn subscriptions_handle(&self) -> Arc<SubscriptionRegistry> {
933 Arc::clone(&self.subscriptions)
934 }
935
936 pub(crate) fn notify_table_changed(&self, table: &str) {
943 for uri in uris_for_table_change(table) {
944 self.subscriptions.notify_updated(&uri);
945 }
946 }
947
948 pub(crate) fn notify_workspace_changed(&self) {
953 for uri in uris_for_workspace_change() {
954 self.subscriptions.notify_updated(uri);
955 }
956 }
957
958 pub(crate) fn notify_resource_list_changed(&self) {
963 self.subscriptions.notify_list_changed();
964 }
965
966 fn client_name(&self) -> Option<String> {
969 self.client_name.lock().ok().and_then(|g| g.clone())
970 }
971
972 #[must_use]
975 pub fn engine_handle(&self) -> Arc<Mutex<Option<Engine>>> {
976 Arc::clone(&self.engine)
977 }
978
979 #[must_use]
981 pub fn watchers_handle(&self) -> Arc<crate::watcher::WatcherRegistry> {
982 Arc::clone(&self.watchers)
983 }
984
985 #[must_use]
988 pub fn attachments_handle(&self) -> Arc<AttachRegistry> {
989 Arc::clone(&self.attachments)
990 }
991
992 #[must_use]
994 pub fn is_read_only(&self) -> bool {
995 self.read_only
996 }
997
998 fn check_writable(&self, operation: &str) -> Result<(), McpError> {
1001 if self.read_only {
1002 Err(McpError::new(
1003 ErrorCode::ReadOnlyViolation,
1004 format!("Operation '{operation}' is not permitted in read-only mode"),
1005 ))
1006 } else {
1007 Ok(())
1008 }
1009 }
1010
1011 fn resolve_db(
1020 &self,
1021 engine: &Engine,
1022 database: Option<&str>,
1023 persist: Option<bool>,
1024 require_writable: bool,
1025 ) -> Result<Option<String>, McpError> {
1026 let effective = match (database, persist) {
1027 (Some(db), _) => Some(db),
1028 (None, Some(true)) => Some(Engine::PERSISTENT_ALIAS),
1029 _ => None,
1030 };
1031 let effective = effective.filter(|s| !s.eq_ignore_ascii_case(crate::attach::LOCAL_ALIAS));
1033
1034 let resolved = engine.resolve_target_db(effective)?;
1035 let primary = engine.primary_db_name();
1036
1037 if resolved == primary {
1038 return Ok(None);
1039 }
1040
1041 if require_writable && resolved != Engine::PERSISTENT_ALIAS {
1042 match self.attachments.get(&resolved) {
1043 None => {
1044 return Err(McpError::new(
1045 ErrorCode::InvalidArgument,
1046 format!(
1047 "database '{resolved}' is not attached. \
1048 Call attach_database first, or use \"persistent\"."
1049 ),
1050 ));
1051 }
1052 Some(entry) if !entry.writable => {
1053 return Err(McpError::new(
1054 ErrorCode::InvalidArgument,
1055 format!(
1056 "database '{resolved}' was attached read-only. \
1057 Re-attach with writable:true to write to it."
1058 ),
1059 ));
1060 }
1061 _ => {}
1062 }
1063 }
1064
1065 Ok(Some(resolved))
1066 }
1067
1068 fn ensure_engine(&self) -> Result<std::sync::MutexGuard<'_, Option<Engine>>, McpError> {
1077 let mut guard = self
1078 .engine
1079 .lock()
1080 .map_err(|_| McpError::new(ErrorCode::InternalError, "Lock poisoned"))?;
1081 if guard.is_none() {
1082 tracing::info!(
1083 persistent_db = self.workspace_path.as_deref().unwrap_or("<ephemeral-only>"),
1084 no_daemon = self.no_daemon,
1085 "initializing hyper engine"
1086 );
1087 let engine = if self.no_daemon {
1088 Engine::new_no_daemon(self.workspace_path.clone())?
1089 } else {
1090 Engine::new(self.workspace_path.clone())?
1091 };
1092 tracing::info!(
1093 ephemeral_path = %engine.ephemeral_path().display(),
1094 persistent_path = ?engine.persistent_path(),
1095 log_dir = %engine.log_dir().display(),
1096 "engine ready"
1097 );
1098 if let Err(e) = self.attachments.replay_all(&engine) {
1107 tracing::warn!(err = %e.message, "failed to replay attachments on new engine");
1108 }
1109 *guard = Some(engine);
1110 if let Ok(mut ready) = self.catalog_ready.lock() {
1114 *ready = false;
1115 }
1116 }
1117 Ok(guard)
1118 }
1119
1120 fn ensure_catalog_ready(&self, engine: &Engine) {
1129 if self.read_only {
1130 return;
1131 }
1132 let Ok(mut ready) = self.catalog_ready.lock() else {
1133 return;
1134 };
1135 if *ready {
1136 return;
1137 }
1138 if let Err(e) = crate::table_catalog::ensure_exists(engine) {
1139 tracing::warn!(err = %e.message, "failed to ensure _table_catalog exists");
1140 }
1141 if let Err(e) = crate::table_catalog::reconcile(engine) {
1142 tracing::warn!(err = %e.message, "failed to reconcile _table_catalog on startup");
1143 }
1144 *ready = true;
1145 }
1146
1147 fn after_ingest_catalog_update(
1160 &self,
1161 engine: &Engine,
1162 table_name: &str,
1163 load_tool: &'static str,
1164 load_params: Option<&str>,
1165 row_count: Option<i64>,
1166 target_db: Option<&str>,
1167 ) {
1168 if let Err(e) = crate::table_catalog::upsert_stub_in(
1169 engine,
1170 table_name,
1171 load_tool,
1172 load_params,
1173 row_count,
1174 true,
1175 target_db,
1176 self.client_name().as_deref(),
1177 ) {
1178 tracing::warn!(
1179 table = %table_name,
1180 target_db = ?target_db,
1181 err = %e.message,
1182 "failed to update _table_catalog after ingest"
1183 );
1184 }
1185 }
1186
1187 #[expect(
1197 clippy::unused_self,
1198 reason = "&self required for method-call dispatch; body uses only engine + target_db"
1199 )]
1200 fn after_execute_catalog_update(&self, engine: &Engine, target_db: Option<&str>) {
1201 if let Err(e) = crate::table_catalog::reconcile_in(engine, None) {
1202 tracing::warn!(
1203 err = %e.message,
1204 "failed to reconcile persistent _table_catalog after execute"
1205 );
1206 }
1207 if let Some(alias) = target_db {
1208 if !alias.eq_ignore_ascii_case(Engine::PERSISTENT_ALIAS) {
1209 if let Err(e) = crate::table_catalog::reconcile_in(engine, Some(alias)) {
1210 tracing::warn!(
1211 target_db = alias,
1212 err = %e.message,
1213 "failed to reconcile user-DB _table_catalog after execute"
1214 );
1215 }
1216 }
1217 }
1218 }
1219
1220 fn with_engine<F, R>(&self, f: F) -> Result<R, McpError>
1229 where
1230 F: FnOnce(&Engine) -> Result<R, McpError>,
1231 {
1232 let mut guard = self.ensure_engine()?;
1233 let engine = guard.as_ref().expect("ensure_engine guarantees Some");
1234 self.ensure_catalog_ready(engine);
1239 if !self.no_daemon {
1242 self.maybe_send_heartbeat();
1243 }
1244 let result = f(engine);
1245 if let Err(e) = &result {
1246 tracing::debug!(code = ?e.code, message = %e.message, "tool call returned error");
1247 if e.code == ErrorCode::ConnectionLost {
1248 tracing::warn!(
1249 "connection to hyperd lost or desynchronized ({}); \
1254 dropping engine so next call reconnects",
1255 e.message
1256 );
1257 *guard = None;
1258 if let Ok(mut ready) = self.catalog_ready.lock() {
1261 *ready = false;
1262 }
1263 if !self.no_daemon {
1267 crate::daemon::health::report_hyperd_error_to_daemon();
1268 }
1269 }
1270 }
1271 result
1272 }
1273
1274 fn maybe_send_heartbeat(&self) {
1278 const HEARTBEAT_INTERVAL: std::time::Duration = std::time::Duration::from_secs(60);
1279 let should_send = self
1280 .last_heartbeat
1281 .lock()
1282 .is_ok_and(|guard| guard.elapsed() >= HEARTBEAT_INTERVAL);
1283 if should_send {
1284 let port = crate::daemon::discovery::resolve_port();
1285 let _ = crate::daemon::health::send_command(port, "HEARTBEAT");
1286 if let Ok(mut guard) = self.last_heartbeat.lock() {
1287 *guard = std::time::Instant::now();
1288 }
1289 }
1290 }
1291
1292 fn with_saved_query_store<F, R>(&self, f: F) -> Result<R, McpError>
1302 where
1303 F: FnOnce(Option<&Engine>) -> Result<R, McpError>,
1304 {
1305 if self.workspace_path.is_some() {
1306 self.with_engine(|engine| f(Some(engine)))
1307 } else {
1308 f(None)
1309 }
1310 }
1311
1312 #[expect(
1313 clippy::unnecessary_wraps,
1314 reason = "signature retained for API symmetry / future fallibility; returning Result/Option keeps callers from breaking when the function later grows failure cases"
1315 )]
1316 fn ok_content(val: Value) -> Result<CallToolResult, rmcp::ErrorData> {
1321 let text = serde_json::to_string_pretty(&val).unwrap_or_default();
1322 let mut result = CallToolResult::structured(val);
1323 result.content = vec![Content::text(text)];
1327 Ok(result)
1328 }
1329
1330 fn fmt_sql(sql: &str) -> String {
1333 let opts = FormatOptions {
1334 indent: Indent::Spaces(2),
1335 uppercase: Some(true),
1336 lines_between_queries: 1,
1337 ..FormatOptions::default()
1338 };
1339 let formatted = sqlformat::format(sql, &SqlQueryParams::None, &opts);
1340 if formatted.trim().is_empty() {
1341 sql.to_owned()
1342 } else {
1343 formatted
1344 }
1345 }
1346
1347 #[expect(
1348 clippy::unnecessary_wraps,
1349 reason = "signature retained for API symmetry / future fallibility; returning Result/Option keeps callers from breaking when the function later grows failure cases"
1350 )]
1351 #[expect(
1352 clippy::needless_pass_by_value,
1353 reason = "call-site ergonomics: function consumes logically-owned parameters, refactoring signatures is not worth per-site churn"
1354 )]
1355 fn err_content(e: McpError) -> Result<CallToolResult, rmcp::ErrorData> {
1360 let err_val = serde_json::to_value(&e).unwrap_or(Value::String(e.to_string()));
1361 let body = json!({"error": err_val});
1362 let text = serde_json::to_string_pretty(&body).unwrap_or_default();
1363 let mut result = CallToolResult::structured_error(body);
1364 result.content = vec![Content::text(text)];
1365 Ok(result)
1366 }
1367}
1368
1369#[tool_router]
1370impl HyperMcpServer {
1371 #[tool(
1373 description = "Ingest inline data (JSON or CSV) and run a SQL query in one call. Creates a temp table, queries, discards."
1374 )]
1375 fn query_data(
1376 &self,
1377 Parameters(params): Parameters<QueryDataParams>,
1378 ) -> Result<CallToolResult, rmcp::ErrorData> {
1379 let result = self.with_engine(|engine| {
1380 let tname = params.table_name.unwrap_or_else(|| "data".into());
1381 let temp_table = format!("_tmp_{}_{}", tname, rand_suffix());
1382 let fmt = params.format.unwrap_or_else(|| detect_format(¶ms.data));
1383 let schema_override = crate::schema::normalize_schema_param(params.schema.as_ref())?;
1384 let opts = IngestOptions {
1385 table: temp_table.clone(),
1386 mode: "replace".into(),
1387 schema_override,
1388 merge_key: None,
1389 target_db: None,
1390 };
1391
1392 let ingest_result = match fmt.as_str() {
1393 "csv" => ingest_csv(engine, ¶ms.data, &opts),
1394 _ => ingest_json(engine, ¶ms.data, &opts),
1395 }?;
1396
1397 let query_sql = params.sql.replace(&tname, &temp_table);
1398 let rows = engine.execute_query_to_json(&query_sql)?;
1399 let _ = engine.execute_command(&format!("DROP TABLE IF EXISTS \"{temp_table}\""));
1400
1401 Ok(json!({
1402 "sql": Self::fmt_sql(¶ms.sql),
1403 "result": rows,
1404 "stats": ingest_result.stats.to_json(),
1405 }))
1406 });
1407
1408 match result {
1409 Ok(val) => Self::ok_content(val),
1410 Err(e) => Self::err_content(e),
1411 }
1412 }
1413
1414 #[tool(
1416 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."
1417 )]
1418 fn query_file(
1419 &self,
1420 Parameters(params): Parameters<QueryFileParams>,
1421 ) -> Result<CallToolResult, rmcp::ErrorData> {
1422 let result = self.with_engine(|engine| {
1423 crate::attach::validate_input_path(¶ms.path, "data file")?;
1424 let stem = std::path::Path::new(¶ms.path)
1425 .file_stem()
1426 .and_then(|s| s.to_str())
1427 .unwrap_or("file")
1428 .to_string();
1429 let tname = params.table_name.unwrap_or_else(|| stem.clone());
1430 let temp_table = format!("_tmp_{}_{}", tname, rand_suffix());
1431 let schema_override = crate::schema::normalize_schema_param(params.schema.as_ref())?;
1432 let opts = IngestOptions {
1433 table: temp_table.clone(),
1434 mode: "replace".into(),
1435 schema_override,
1436 merge_key: None,
1437 target_db: None,
1438 };
1439
1440 let ingest_result = if let Some(ref json_path) = params.json_extract_path {
1441 let raw = std::fs::read_to_string(¶ms.path).map_err(|e| {
1442 McpError::new(
1443 ErrorCode::FileNotFound,
1444 format!("Cannot read file '{}': {e}", params.path),
1445 )
1446 })?;
1447 let extracted = crate::ingest::extract_json_path(&raw, json_path)?;
1448 let array_text = crate::ingest::normalize_json_or_jsonl(&extracted)?;
1449 let mut result = ingest_json(engine, &array_text, &opts)?;
1450 result.stats.operation = "query_file".into();
1451 result.stats.bytes_read = std::fs::metadata(¶ms.path).map_or(0, |m| m.len());
1452 result.stats.file_format = Some("json".into());
1453 result
1454 } else {
1455 match detect_file_format(std::path::Path::new(¶ms.path)) {
1456 InferredFileFormat::Parquet => ingest_parquet_file(engine, ¶ms.path, &opts),
1457 InferredFileFormat::ArrowIpc => {
1458 ingest_arrow_ipc_file(engine, ¶ms.path, &opts)
1459 }
1460 InferredFileFormat::Json => ingest_json_file(engine, ¶ms.path, &opts),
1461 InferredFileFormat::Csv => ingest_csv_file(engine, ¶ms.path, &opts),
1462 }?
1463 };
1464
1465 let query_sql = params.sql.replace(&tname, &temp_table);
1466 let rows = engine.execute_query_to_json(&query_sql)?;
1467 let _ = engine.execute_command(&format!("DROP TABLE IF EXISTS \"{temp_table}\""));
1468
1469 Ok(json!({
1470 "sql": Self::fmt_sql(¶ms.sql),
1471 "result": rows,
1472 "stats": ingest_result.stats.to_json(),
1473 }))
1474 });
1475
1476 match result {
1477 Ok(val) => Self::ok_content(val),
1478 Err(e) => Self::err_content(e),
1479 }
1480 }
1481
1482 #[tool(
1484 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))."
1485 )]
1486 fn load_data(
1487 &self,
1488 Parameters(params): Parameters<LoadDataParams>,
1489 ) -> Result<CallToolResult, rmcp::ErrorData> {
1490 if let Err(e) = self.check_writable("load_data") {
1491 return Self::err_content(e);
1492 }
1493 let table_name = params.table.clone();
1494 let mode = params.mode.clone().unwrap_or_else(|| "replace".into());
1499 let result = self.with_engine(|engine| {
1500 let target_db =
1501 self.resolve_db(engine, params.database.as_deref(), params.persist, true)?;
1502 let fmt = params.format.unwrap_or_else(|| detect_format(¶ms.data));
1503 let schema_override = crate::schema::normalize_schema_param(params.schema.as_ref())?;
1504 let opts = IngestOptions {
1505 table: params.table.clone(),
1506 mode: mode.clone(),
1507 schema_override,
1508 merge_key: None,
1509 target_db: target_db.clone(),
1510 };
1511
1512 let ingest_result = match fmt.as_str() {
1513 "csv" => ingest_csv(engine, ¶ms.data, &opts),
1514 _ => ingest_json(engine, ¶ms.data, &opts),
1515 }?;
1516
1517 let schema_json: Vec<Value> = ingest_result
1518 .schema
1519 .iter()
1520 .map(|c| {
1521 json!({
1522 "name": c.name,
1523 "type": c.hyper_type,
1524 "nullable": c.nullable,
1525 })
1526 })
1527 .collect();
1528
1529 {
1535 let load_params = serde_json::to_string(&json!({
1536 "mode": mode,
1537 "format": fmt,
1538 "database": target_db.as_deref().unwrap_or("local"),
1539 }))
1540 .ok();
1541 self.after_ingest_catalog_update(
1542 engine,
1543 ¶ms.table,
1544 "load_data",
1545 load_params.as_deref(),
1546 i64::try_from(ingest_result.rows).ok(),
1547 target_db.as_deref(),
1548 );
1549 }
1550
1551 Ok(json!({
1552 "rows": ingest_result.rows,
1553 "schema": schema_json,
1554 "stats": ingest_result.stats.to_json(),
1555 }))
1556 });
1557
1558 match result {
1559 Ok(val) => {
1560 self.notify_table_changed(&table_name);
1561 if mode == "replace" {
1562 self.notify_resource_list_changed();
1566 }
1567 Self::ok_content(val)
1568 }
1569 Err(e) => Self::err_content(e),
1570 }
1571 }
1572
1573 #[tool(
1575 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."
1576 )]
1577 fn load_file(
1578 &self,
1579 Parameters(params): Parameters<LoadFileParams>,
1580 ) -> Result<CallToolResult, rmcp::ErrorData> {
1581 if let Err(e) = self.check_writable("load_file") {
1582 return Self::err_content(e);
1583 }
1584 let table_name = params.table.clone();
1585 let mode = params.mode.clone().unwrap_or_else(|| "replace".into());
1586 let merge_key_vec = match validate_merge_args(&mode, params.merge_key) {
1590 Ok(v) => v,
1591 Err(e) => return Self::err_content(e),
1592 };
1593 let result = self.with_engine(|engine| {
1598 let target_db =
1599 self.resolve_db(engine, params.database.as_deref(), params.persist, true)?;
1600 crate::attach::validate_input_path(¶ms.path, "data file")?;
1601 let schema_override = crate::schema::normalize_schema_param(params.schema.as_ref())?;
1602 let opts = IngestOptions {
1603 table: params.table.clone(),
1604 mode: mode.clone(),
1605 schema_override,
1606 merge_key: merge_key_vec.clone(),
1607 target_db: target_db.clone(),
1608 };
1609
1610 let ingest_result = if let Some(ref json_path) = params.json_extract_path {
1611 let raw = std::fs::read_to_string(¶ms.path).map_err(|e| {
1612 McpError::new(
1613 ErrorCode::FileNotFound,
1614 format!("Cannot read file '{}': {e}", params.path),
1615 )
1616 })?;
1617 let extracted = crate::ingest::extract_json_path(&raw, json_path)?;
1618 let array_text = crate::ingest::normalize_json_or_jsonl(&extracted)?;
1619 let mut result = ingest_json(engine, &array_text, &opts)?;
1620 result.stats.operation = "load_file".into();
1621 result.stats.bytes_read = std::fs::metadata(¶ms.path).map_or(0, |m| m.len());
1622 result.stats.file_format = Some("json".into());
1623 result
1624 } else {
1625 match detect_file_format(std::path::Path::new(¶ms.path)) {
1626 InferredFileFormat::Parquet => ingest_parquet_file(engine, ¶ms.path, &opts),
1627 InferredFileFormat::ArrowIpc => {
1628 ingest_arrow_ipc_file(engine, ¶ms.path, &opts)
1629 }
1630 InferredFileFormat::Json => ingest_json_file(engine, ¶ms.path, &opts),
1631 InferredFileFormat::Csv => ingest_csv_file(engine, ¶ms.path, &opts),
1632 }?
1633 };
1634
1635 let schema_changed = ingest_result.stats.schema_changed;
1639
1640 let schema_json: Vec<Value> = ingest_result
1641 .schema
1642 .iter()
1643 .map(|c| {
1644 json!({
1645 "name": c.name,
1646 "type": c.hyper_type,
1647 "nullable": c.nullable,
1648 })
1649 })
1650 .collect();
1651
1652 {
1654 let load_params = serde_json::to_string(&json!({
1655 "source_path": params.path,
1656 "mode": mode,
1657 "schema": params.schema,
1658 "json_extract_path": params.json_extract_path,
1659 "merge_key": merge_key_vec,
1660 "database": target_db.as_deref().unwrap_or("local"),
1661 }))
1662 .ok();
1663 self.after_ingest_catalog_update(
1664 engine,
1665 ¶ms.table,
1666 "load_file",
1667 load_params.as_deref(),
1668 i64::try_from(ingest_result.rows).ok(),
1669 target_db.as_deref(),
1670 );
1671 }
1672
1673 Ok((
1674 json!({
1675 "rows": ingest_result.rows,
1676 "schema": schema_json,
1677 "stats": ingest_result.stats.to_json(),
1678 }),
1679 schema_changed,
1680 ))
1681 });
1682
1683 match result {
1684 Ok((val, schema_changed)) => {
1685 self.notify_table_changed(&table_name);
1686 if mode == "replace" || schema_changed {
1694 self.notify_resource_list_changed();
1695 }
1696 Self::ok_content(val)
1697 }
1698 Err(e) => Self::err_content(e),
1699 }
1700 }
1701
1702 #[tool(
1706 description = "Ingest multiple files in parallel. Each entry is equivalent to a standalone `load_file` call (same formats and same format-selection guidance: prefer Parquet > CSV > Arrow IPC > JSON for large imports). The batch runs across a pool of async connections sized by `concurrency` (default `min(files.len(), 8)`), so independent files finish roughly in max-time rather than sum-time. Per-file errors are captured in the response and do not abort the rest of the batch; the top-level call still returns Ok. For Apache Iceberg tables, call `load_iceberg` per table instead — this tool only handles single-file formats.\n\nUse `database` (or shorthand `persist: true`) to target a non-primary database; the same value applies to every entry in the batch. **Note: `mode = \"merge\"` is not supported here — use `load_file` once per file when you need merge/upsert semantics.**"
1707 )]
1708 fn load_files(
1709 &self,
1710 Parameters(params): Parameters<LoadFilesParams>,
1711 ) -> Result<CallToolResult, rmcp::ErrorData> {
1712 use hyperdb_api::pool::{create_pool, PoolConfig};
1713 use hyperdb_api::CreateMode;
1714
1715 if let Err(e) = self.check_writable("load_files") {
1716 return Self::err_content(e);
1717 }
1718 if params.files.is_empty() {
1719 return Self::err_content(McpError::new(
1720 ErrorCode::EmptyData,
1721 "load_files: `files` must not be empty",
1722 ));
1723 }
1724
1725 for (idx, entry) in params.files.iter().enumerate() {
1732 if let Err(mut e) = crate::attach::validate_input_path(&entry.path, "data file") {
1733 e.message = format!("entry {idx} (table '{}'): {}", entry.table, e.message);
1734 return Self::err_content(e);
1735 }
1736 let mode = entry.mode.as_deref().unwrap_or("replace");
1737 if mode == "merge" || entry.merge_key.is_some() {
1738 return Self::err_content(McpError::new(
1739 ErrorCode::InvalidArgument,
1740 format!(
1741 "load_files does not support mode=merge yet (entry {idx}, table \
1742 '{}'). Call load_file once per file when you need merge semantics.",
1743 entry.table
1744 ),
1745 ));
1746 }
1747 }
1748
1749 let (endpoint, workspace, target_db) = match self.with_engine(|engine| {
1755 let target_db =
1756 self.resolve_db(engine, params.database.as_deref(), params.persist, true)?;
1757 let endpoint = engine.hyperd_endpoint()?;
1758 let workspace = match target_db.as_deref() {
1759 None => engine.ephemeral_path().to_string_lossy().to_string(),
1760 Some(alias) if alias.eq_ignore_ascii_case(Engine::PERSISTENT_ALIAS) => engine
1761 .persistent_path()
1762 .ok_or_else(|| {
1763 McpError::new(
1764 ErrorCode::InvalidArgument,
1765 "target 'persistent' but the server is in --ephemeral-only mode",
1766 )
1767 })?
1768 .to_string_lossy()
1769 .to_string(),
1770 Some(alias) => {
1771 let entry = self.attachments.get(alias).ok_or_else(|| {
1772 McpError::new(
1773 ErrorCode::InvalidArgument,
1774 format!("database '{alias}' is not attached"),
1775 )
1776 })?;
1777 let crate::attach::AttachSource::LocalFile { path } = &entry.source;
1778 path.to_string_lossy().to_string()
1779 }
1780 };
1781 Ok((endpoint, workspace, target_db))
1782 }) {
1783 Ok(v) => v,
1784 Err(e) => return Self::err_content(e),
1785 };
1786
1787 let file_count = params.files.len();
1790 let concurrency = params
1791 .concurrency
1792 .map_or(8, |n| n as usize)
1793 .min(file_count)
1794 .clamp(1, 16);
1795
1796 let pool = match create_pool(
1797 PoolConfig::new(endpoint, workspace)
1798 .create_mode(CreateMode::DoNotCreate)
1799 .max_size(concurrency),
1800 ) {
1801 Ok(p) => Arc::new(p),
1802 Err(e) => {
1803 return Self::err_content(McpError::new(
1804 ErrorCode::InternalError,
1805 format!("Failed to build connection pool for load_files: {e}"),
1806 ))
1807 }
1808 };
1809
1810 let Ok(rt) = tokio::runtime::Handle::try_current() else {
1813 return Self::err_content(McpError::new(
1814 ErrorCode::InternalError,
1815 "load_files must run inside a tokio runtime",
1816 ));
1817 };
1818
1819 #[derive(Default)]
1822 struct EntryOutcome {
1823 table: String,
1824 ok: Option<(u64, Vec<Value>, Value)>,
1825 err: Option<(ErrorCode, String)>,
1826 replace_mode: bool,
1827 }
1828
1829 let outcomes: Vec<EntryOutcome> = tokio::task::block_in_place(|| {
1830 rt.block_on(async {
1831 let mut set = tokio::task::JoinSet::new();
1832 for (idx, entry) in params.files.into_iter().enumerate() {
1833 let pool = Arc::clone(&pool);
1834 let entry_target_db = target_db.clone();
1835 set.spawn(async move {
1836 let mode = entry.mode.clone().unwrap_or_else(|| "replace".into());
1837 let replace_mode = mode == "replace";
1838 let mut out = EntryOutcome {
1839 table: entry.table.clone(),
1840 replace_mode,
1841 ..Default::default()
1842 };
1843
1844 let schema_override =
1849 match crate::schema::normalize_schema_param(entry.schema.as_ref()) {
1850 Ok(v) => v,
1851 Err(e) => {
1852 out.err = Some((e.code, e.message));
1853 return (idx, out);
1854 }
1855 };
1856 let _ = &entry_target_db;
1865 let opts = IngestOptions {
1866 table: entry.table.clone(),
1867 mode: mode.clone(),
1868 schema_override,
1869 merge_key: None,
1870 target_db: None,
1871 };
1872
1873 let mut conn = match pool.get().await {
1876 Ok(c) => c,
1877 Err(e) => {
1878 out.err = Some((
1879 ErrorCode::InternalError,
1880 format!("Failed to check out connection: {e}"),
1881 ));
1882 return (idx, out);
1883 }
1884 };
1885
1886 let ingest_res = if let Some(ref json_path) = entry.json_extract_path {
1891 let raw = match std::fs::read_to_string(&entry.path) {
1892 Ok(s) => s,
1893 Err(e) => {
1894 out.err = Some((
1895 ErrorCode::FileNotFound,
1896 format!("Cannot read file '{}': {e}", entry.path),
1897 ));
1898 return (idx, out);
1899 }
1900 };
1901 let extracted = match crate::ingest::extract_json_path(&raw, json_path)
1902 {
1903 Ok(v) => v,
1904 Err(e) => {
1905 out.err = Some((e.code, e.message));
1906 return (idx, out);
1907 }
1908 };
1909 let array_text =
1910 match crate::ingest::normalize_json_or_jsonl(&extracted) {
1911 Ok(v) => v,
1912 Err(e) => {
1913 out.err = Some((e.code, e.message));
1914 return (idx, out);
1915 }
1916 };
1917 crate::ingest::ingest_json_async(&mut conn, &array_text, &opts)
1918 .await
1919 .map(|mut r| {
1920 r.stats.operation = "load_file".into();
1921 r.stats.bytes_read =
1922 std::fs::metadata(&entry.path).map_or(0, |m| m.len());
1923 r.stats.file_format = Some("json".into());
1924 r
1925 })
1926 } else {
1927 match detect_file_format(std::path::Path::new(&entry.path)) {
1928 InferredFileFormat::Parquet => {
1929 ingest_parquet_file_async(&mut conn, &entry.path, &opts).await
1930 }
1931 InferredFileFormat::ArrowIpc => {
1932 ingest_arrow_ipc_file_async(&mut conn, &entry.path, &opts).await
1933 }
1934 InferredFileFormat::Json => {
1935 ingest_json_file_async(&mut conn, &entry.path, &opts).await
1936 }
1937 InferredFileFormat::Csv => {
1938 ingest_csv_file_async(&mut conn, &entry.path, &opts).await
1939 }
1940 }
1941 };
1942
1943 match ingest_res {
1944 Ok(r) => {
1945 let schema_json: Vec<Value> = r
1946 .schema
1947 .iter()
1948 .map(|c| {
1949 json!({
1950 "name": c.name,
1951 "type": c.hyper_type,
1952 "nullable": c.nullable,
1953 })
1954 })
1955 .collect();
1956 out.ok = Some((r.rows, schema_json, r.stats.to_json()));
1957 }
1958 Err(e) => {
1959 out.err = Some((e.code, e.message));
1960 }
1961 }
1962
1963 (idx, out)
1964 });
1965 }
1966
1967 let mut collected: Vec<Option<EntryOutcome>> =
1970 (0..file_count).map(|_| None).collect();
1971 while let Some(joined) = set.join_next().await {
1972 match joined {
1973 Ok((idx, outcome)) => collected[idx] = Some(outcome),
1974 Err(e) => {
1975 tracing::warn!("load_files task join error: {e}");
1978 }
1979 }
1980 }
1981 collected.into_iter().flatten().collect()
1982 })
1983 });
1984
1985 let mut any_replace_succeeded = false;
1989 let mut tables_to_notify: Vec<String> = Vec::new();
1990 let results_json: Vec<Value> = outcomes
1991 .iter()
1992 .map(|o| match (&o.ok, &o.err) {
1993 (Some((rows, schema, stats)), _) => {
1994 tables_to_notify.push(o.table.clone());
1995 if o.replace_mode {
1996 any_replace_succeeded = true;
1997 }
1998 json!({
1999 "table": o.table,
2000 "rows": rows,
2001 "schema": schema,
2002 "stats": stats,
2003 })
2004 }
2005 (None, Some((code, msg))) => json!({
2006 "table": o.table,
2007 "error": {
2008 "code": format!("{:?}", code),
2009 "message": msg,
2010 }
2011 }),
2012 (None, None) => json!({
2015 "table": o.table,
2016 "error": {
2017 "code": "InternalError",
2018 "message": "load_files task produced no outcome",
2019 }
2020 }),
2021 })
2022 .collect();
2023
2024 if let Err(e) = self.with_engine(|engine| {
2028 for o in &outcomes {
2029 if let Some((rows, _, _)) = &o.ok {
2030 self.after_ingest_catalog_update(
2031 engine,
2032 &o.table,
2033 "load_file",
2034 None,
2035 i64::try_from(*rows).ok(),
2036 target_db.as_deref(),
2037 );
2038 }
2039 }
2040 Ok(())
2041 }) {
2042 tracing::warn!("load_files: catalog update batch failed: {}", e.message);
2043 }
2044
2045 for t in &tables_to_notify {
2046 self.notify_table_changed(t);
2047 }
2048 if any_replace_succeeded {
2049 self.notify_resource_list_changed();
2050 }
2051
2052 let success_count = outcomes.iter().filter(|o| o.ok.is_some()).count();
2053 let failure_count = outcomes.len() - success_count;
2054
2055 Self::ok_content(json!({
2056 "results": results_json,
2057 "summary": {
2058 "total": outcomes.len(),
2059 "succeeded": success_count,
2060 "failed": failure_count,
2061 "concurrency": concurrency,
2062 }
2063 }))
2064 }
2065
2066 #[tool(
2069 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."
2070 )]
2071 fn load_iceberg(
2072 &self,
2073 Parameters(params): Parameters<LoadIcebergParams>,
2074 ) -> Result<CallToolResult, rmcp::ErrorData> {
2075 if let Err(e) = self.check_writable("load_iceberg") {
2076 return Self::err_content(e);
2077 }
2078 let table_name = params.table.clone();
2079 let mode = params.mode.clone().unwrap_or_else(|| "replace".into());
2080 let opts = crate::lakehouse::IcebergIngestOptions {
2081 table: params.table.clone(),
2082 mode: mode.clone(),
2083 metadata_filename: params.metadata_filename.clone(),
2084 version_as_of: params.version_as_of,
2085 };
2086
2087 let result = self.with_engine(|engine| {
2088 crate::attach::validate_input_path(¶ms.path, "iceberg table")?;
2090 let ingest_result =
2091 crate::lakehouse::ingest_iceberg_table(engine, ¶ms.path, &opts)?;
2092
2093 let schema_json: Vec<Value> = ingest_result
2094 .schema
2095 .iter()
2096 .map(|c| {
2097 json!({
2098 "name": c.name,
2099 "type": c.hyper_type,
2100 "nullable": c.nullable,
2101 })
2102 })
2103 .collect();
2104
2105 let load_params = serde_json::to_string(&json!({
2106 "source_path": params.path,
2107 "mode": mode,
2108 "format": "iceberg",
2109 "metadata_filename": params.metadata_filename,
2110 "version_as_of": params.version_as_of,
2111 }))
2112 .ok();
2113 self.after_ingest_catalog_update(
2114 engine,
2115 ¶ms.table,
2116 "load_iceberg",
2117 load_params.as_deref(),
2118 i64::try_from(ingest_result.rows).ok(),
2119 None,
2120 );
2121
2122 Ok(json!({
2123 "rows": ingest_result.rows,
2124 "schema": schema_json,
2125 "stats": ingest_result.stats.to_json(),
2126 }))
2127 });
2128
2129 match result {
2130 Ok(val) => {
2131 self.notify_table_changed(&table_name);
2132 if mode == "replace" {
2133 self.notify_resource_list_changed();
2134 }
2135 Self::ok_content(val)
2136 }
2137 Err(e) => Self::err_content(e),
2138 }
2139 }
2140
2141 #[tool(
2143 description = "Run a read-only SQL query (SELECT, WITH, EXPLAIN, SHOW, VALUES) against the workspace. For DDL/DML use the execute tool."
2144 )]
2145 fn query(
2146 &self,
2147 Parameters(params): Parameters<QueryParams>,
2148 ) -> Result<CallToolResult, rmcp::ErrorData> {
2149 let result = self.with_engine(|engine| {
2150 if !is_read_only_sql(¶ms.sql) {
2151 return Err(McpError::new(
2152 ErrorCode::SqlError,
2153 "The query tool only accepts read-only SQL (SELECT, WITH, EXPLAIN, SHOW, VALUES). Use the execute tool for DDL/DML.",
2154 ));
2155 }
2156 let target_db = self.resolve_db(engine, params.database.as_deref(), None, false)?;
2159 let _search_guard = match target_db {
2160 Some(ref alias) => Some(engine.scoped_search_path(alias)?),
2161 None => None,
2162 };
2163 const MAX_QUERY_ROWS: usize = 10_000;
2167
2168 let timer = crate::stats::StatsTimer::start();
2169 let mut rows = engine.execute_query_to_json(¶ms.sql)?;
2170 let total_rows = rows.len();
2171 let truncated = total_rows > MAX_QUERY_ROWS;
2172 if truncated {
2173 rows.truncate(MAX_QUERY_ROWS);
2174 }
2175 let elapsed = timer.elapsed_ms();
2176 let stats = crate::stats::QueryStats {
2177 operation: "query".into(),
2178 rows_returned: rows.len() as u64,
2179 rows_scanned: 0,
2180 elapsed_ms: elapsed,
2181 result_size_bytes: serde_json::to_string(&rows).map_or(0, |s| s.len() as u64),
2182 tables_touched: vec![],
2183 };
2184 let payload = if truncated {
2185 json!({
2186 "result": rows,
2187 "stats": stats.to_json(),
2188 "truncated": true,
2189 "total_rows": total_rows,
2190 "rows_returned": MAX_QUERY_ROWS,
2191 "hint": format!(
2192 "Result set has {total_rows} rows; only the first {MAX_QUERY_ROWS} \
2193 are shown. Add a LIMIT clause, aggregate with GROUP BY, or use \
2194 the `export` tool to write the full result to a file."
2195 ),
2196 })
2197 } else {
2198 json!({
2199 "result": rows,
2200 "stats": stats.to_json(),
2201 })
2202 };
2203 Ok((params.sql.clone(), payload))
2204 });
2205
2206 match result {
2207 Ok((sql, val)) => {
2208 let formatted_sql = Self::fmt_sql(&sql);
2209 let json_text = serde_json::to_string_pretty(&val).unwrap_or_default();
2210 Ok(CallToolResult::success(vec![
2211 Content::text(format!("```sql\n{formatted_sql}\n```")),
2212 Content::text(json_text),
2213 ]))
2214 }
2215 Err(e) => Self::err_content(e),
2216 }
2217 }
2218
2219 #[tool(
2221 description = "Execute one or more DDL/DML statements as an atomic batch. `sql` is an array of statements; pass `[\"SQL\"]` for a single statement or `[\"UPDATE …\", \"INSERT …\"]` for an atomic upsert. Multi-statement batches run inside a transaction — if any statement fails, all are rolled back. Mixing DDL with DML in one batch is rejected (Hyper aborts such transactions). Disabled in read-only mode."
2222 )]
2223 fn execute(
2224 &self,
2225 Parameters(params): Parameters<ExecuteParams>,
2226 ) -> Result<CallToolResult, rmcp::ErrorData> {
2227 if let Err(e) = self.check_writable("execute") {
2228 return Self::err_content(e);
2229 }
2230 if let Err(e) = validate_execute_batch(¶ms.sql) {
2235 return Self::err_content(e);
2236 }
2237 let any_structural = params
2238 .sql
2239 .iter()
2240 .any(|s| matches!(classify_statement(s), StatementKind::Ddl));
2241 let result = self.with_engine(|engine| {
2242 let target_db = self.resolve_db(engine, params.database.as_deref(), None, true)?;
2246 let _search_guard = match target_db {
2247 Some(ref alias) => Some(engine.scoped_search_path(alias)?),
2248 None => None,
2249 };
2250 let total_timer = crate::stats::StatsTimer::start();
2251 let (per_statement, affected_total, operation): (Vec<Value>, u64, &'static str) =
2252 if params.sql.len() == 1 {
2253 let stmt = ¶ms.sql[0];
2257 let t = crate::stats::StatsTimer::start();
2258 let affected = engine.execute_command(stmt)?;
2259 (
2260 vec![json!({
2261 "sql": Self::fmt_sql(stmt),
2262 "affected_rows": affected,
2263 "elapsed_ms": t.elapsed_ms(),
2264 })],
2265 affected,
2266 "command",
2267 )
2268 } else {
2269 let stmts = ¶ms.sql;
2270 let (results, total) = engine.execute_in_transaction(|engine| {
2271 let mut out = Vec::with_capacity(stmts.len());
2272 let mut total: u64 = 0;
2273 for (idx, stmt) in stmts.iter().enumerate() {
2274 let t = crate::stats::StatsTimer::start();
2275 let affected = engine.execute_command(stmt).map_err(|e| {
2276 let rollback_note = format!(
2281 "Failing SQL: {sql}. All previous statements in this batch were rolled back.",
2282 sql = Self::fmt_sql(stmt)
2283 );
2284 let combined = match e.suggestion.as_deref() {
2285 Some(orig) => format!("{orig} | {rollback_note}"),
2286 None => rollback_note,
2287 };
2288 McpError::new(
2289 e.code,
2290 format!(
2291 "statement {} of {} failed: {}",
2292 idx + 1,
2293 stmts.len(),
2294 e.message
2295 ),
2296 )
2297 .with_suggestion(combined)
2298 })?;
2299 total = total.saturating_add(affected);
2303 out.push(json!({
2304 "sql": Self::fmt_sql(stmt),
2305 "affected_rows": affected,
2306 "elapsed_ms": t.elapsed_ms(),
2307 }));
2308 }
2309 Ok((out, total))
2310 })?;
2311 (results, total, "transaction")
2312 };
2313 let elapsed = total_timer.elapsed_ms();
2314 if any_structural {
2327 self.after_execute_catalog_update(engine, target_db.as_deref());
2328 }
2329 Ok(json!({
2330 "statements": per_statement.len(),
2331 "affected_rows": affected_total,
2332 "per_statement": per_statement,
2333 "stats": { "operation": operation, "elapsed_ms": elapsed },
2334 }))
2335 });
2336
2337 match result {
2338 Ok(val) => {
2339 self.notify_workspace_changed();
2344 if any_structural {
2345 self.notify_resource_list_changed();
2346 }
2347 Self::ok_content(val)
2348 }
2349 Err(e) => Self::err_content(e),
2350 }
2351 }
2352
2353 #[tool(
2355 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."
2356 )]
2357 fn sample(
2358 &self,
2359 Parameters(params): Parameters<SampleParams>,
2360 ) -> Result<CallToolResult, rmcp::ErrorData> {
2361 let result = self.with_engine(|engine| {
2362 let target_db = self.resolve_db(engine, params.database.as_deref(), None, false)?;
2363 let timer = crate::stats::StatsTimer::start();
2364 let n = params.n.unwrap_or(5);
2365 let mut sample = engine.sample_table_in(target_db.as_deref(), ¶ms.table, n)?;
2366 let elapsed = timer.elapsed_ms();
2367 if let Some(obj) = sample.as_object_mut() {
2368 obj.insert(
2369 "stats".into(),
2370 json!({ "operation": "sample", "elapsed_ms": elapsed }),
2371 );
2372 }
2373 Ok(sample)
2374 });
2375
2376 match result {
2377 Ok(val) => Self::ok_content(val),
2378 Err(e) => Self::err_content(e),
2379 }
2380 }
2381
2382 #[tool(
2384 description = "Render a chart (bar, line, scatter, or histogram) from a SQL query. Writes the image to disk by default and returns a short stats blob with the path — use `Read(path)` to display it (this keeps the MCP transcript small). Set `inline=true` to also receive the PNG/SVG bytes inline in the tool result; combine with `output_path` to get both.\n\n**Data shape:** The query must return long-format data with one numeric `y` column. For multi-series charts, use a `series` column to split by category. If your data is wide-format (multiple value columns), reshape it with `UNION ALL` into (label, series, value) tuples before charting.\n\n**DATE/TIMESTAMP x-axis:** Line and scatter charts auto-detect non-numeric x columns. DATE, TIMESTAMP, and TIMESTAMPTZ values render with a **proportional time axis** — gaps between data points reflect real wall-clock time (4.5 h gap and 17 h gap don't look the same). Tick labels are formatted in the input kind: `%Y-%m-%d` for DATE, `%Y-%m-%d %H:%M:%S` for TIMESTAMP, with the originating timezone offset preserved for TIMESTAMPTZ. TEXT x columns fall back to evenly-spaced categorical mode. Set `x_as_category: true` to force categorical layout on temporal data (useful when even spacing reads better than proportional gaps).\n\n- `output_path`: explicit destination file path. Parent directory is created automatically (no need to pre-create it). If omitted, a file is auto-generated under the system temp dir as `hyperdb-charts/chart-<ts>-<pid>-<n>.<ext>`.\n- `inline`: when true, return the image bytes inline. Without `output_path`, suppresses the disk write entirely. With `output_path`, writes to disk AND returns inline. Defaults to false.\n- `format`: \"png\" (default) or \"svg\". Auto-derived from `output_path` extension when omitted. A mismatch between `format` and the path extension returns `INVALID_ARGUMENT`.\n- `overwrite`: default true. Set false to refuse overwriting an existing file (returns `PERMISSION_DENIED`).\n- `x_range` / `y_range`: fix axis extents across multiple charts (e.g. x_range=[0,1500], y_range=[0,1]).\n- `color_map`: stable per-series hex colors (e.g. {\"India\":\"#e41a1c\",\"China\":\"#ff7f0e\"}).\n- `label_points=true`: annotate each point with its series name instead of showing a legend — best when each series has exactly one point."
2385 )]
2386 fn chart(
2387 &self,
2388 Parameters(params): Parameters<ChartParams>,
2389 ) -> Result<CallToolResult, rmcp::ErrorData> {
2390 let result = self.with_engine(|engine| {
2391 if !is_read_only_sql(¶ms.sql) {
2392 return Err(McpError::new(
2393 ErrorCode::SqlError,
2394 "The chart tool only accepts read-only SQL (SELECT, WITH, EXPLAIN, SHOW, VALUES).",
2395 ));
2396 }
2397
2398 if let Some(out) = params.output_path.as_deref() {
2401 crate::attach::validate_output_path(out, "chart output")?;
2402 }
2403 let format = crate::chart::resolve_chart_format(
2406 params.format.as_deref(),
2407 params.output_path.as_deref(),
2408 )?;
2409
2410 let target_db = self.resolve_db(engine, params.database.as_deref(), None, false)?;
2413 let _search_guard = match target_db {
2414 Some(ref alias) => Some(engine.scoped_search_path(alias)?),
2415 None => None,
2416 };
2417
2418 let timer = crate::stats::StatsTimer::start();
2419 let rows = engine.execute_query_to_json(¶ms.sql)?;
2420
2421 let color_map = params
2424 .color_map
2425 .as_ref()
2426 .map(|m| {
2427 m.iter()
2428 .filter_map(|(k, v)| {
2429 crate::chart::parse_hex_color(v)
2430 .map(|c| (k.clone(), c))
2431 })
2432 .collect::<std::collections::HashMap<_, _>>()
2433 })
2434 .unwrap_or_default();
2435
2436 let opts = ChartOptions {
2437 chart_type: ChartType::parse(¶ms.chart_type)?,
2438 x_column: params.x.clone(),
2439 y_column: params.y.clone(),
2440 series_column: params.series.clone(),
2441 title: params.title.clone(),
2442 format,
2443 width: params.width.unwrap_or(800).clamp(200, 4096),
2444 height: params.height.unwrap_or(480).clamp(150, 4096),
2445 bins: params.bins.unwrap_or(20).clamp(1, 500),
2446 x_as_category: params.x_as_category,
2447 x_range: params.x_range,
2448 y_range: params.y_range,
2449 color_map,
2450 label_points: params.label_points.unwrap_or(false),
2451 };
2452
2453 let chart = render_chart(&rows, &opts)?;
2454
2455 let disposition = crate::chart::resolve_chart_disposition(
2459 params.inline.unwrap_or(false),
2460 params.output_path.as_deref(),
2461 opts.format,
2462 );
2463 let overwrite = params.overwrite.unwrap_or(true);
2464 if let Some(path) = disposition.path() {
2465 crate::chart::write_chart_to_disk(path, &chart.bytes, overwrite)?;
2466 }
2467
2468 let elapsed = timer.elapsed_ms();
2469 Ok((chart, elapsed, opts, disposition))
2470 });
2471
2472 match result {
2473 Ok((chart, elapsed_ms, opts, disposition)) => {
2474 let format_str = match opts.format {
2475 ChartFormat::Png => "png",
2476 ChartFormat::Svg => "svg",
2477 };
2478 let wants_inline = disposition.wants_inline();
2479 let output_path_str = disposition.path().map(|p| p.to_string_lossy().into_owned());
2480
2481 let mut stats = serde_json::Map::new();
2482 stats.insert("operation".into(), json!("chart"));
2483 stats.insert("rows_plotted".into(), json!(chart.rows_plotted));
2484 stats.insert("elapsed_ms".into(), json!(elapsed_ms));
2485 stats.insert("format".into(), json!(format_str));
2486 stats.insert("bytes".into(), json!(chart.bytes.len()));
2487 stats.insert("width".into(), json!(opts.width));
2488 stats.insert("height".into(), json!(opts.height));
2489 stats.insert("inline".into(), json!(wants_inline));
2490 if let Some(p) = output_path_str {
2491 stats.insert("output_path".into(), json!(p));
2492 }
2493 let stats_text =
2494 serde_json::to_string_pretty(&Value::Object(stats)).unwrap_or_default();
2495
2496 let mut content = Vec::with_capacity(2);
2497 if wants_inline {
2498 let b64 = base64::engine::general_purpose::STANDARD.encode(&chart.bytes);
2499 content.push(Content::image(b64, chart.mime_type.to_string()));
2500 }
2501 content.push(Content::text(stats_text));
2502 Ok(CallToolResult::success(content))
2503 }
2504 Err(e) => Self::err_content(e),
2505 }
2506 }
2507
2508 #[tool(
2511 description = "Watch a directory for files to auto-ingest. Producers write data file + companion <name>.ready sentinel; the watcher appends the data file to the given table and deletes both on success. Use `database` (or shorthand `persist: true`) to target a non-primary database — the watcher's connection pool opens that file directly. `detach_database` rejects while a watcher is active; call `unwatch_directory` first. Disabled in read-only mode."
2512 )]
2513 fn watch_directory(
2514 &self,
2515 Parameters(params): Parameters<WatchDirectoryParams>,
2516 ) -> Result<CallToolResult, rmcp::ErrorData> {
2517 if let Err(e) = self.check_writable("watch_directory") {
2518 return Self::err_content(e);
2519 }
2520 let canonical = match crate::attach::validate_input_path(¶ms.path, "watch directory") {
2521 Ok(p) => p,
2522 Err(e) => return Self::err_content(e),
2523 };
2524 match self.ensure_engine() {
2527 Ok(guard) => drop(guard),
2528 Err(e) => return Self::err_content(e),
2529 }
2530
2531 let target_db = match self.with_engine(|engine| {
2535 self.resolve_db(engine, params.database.as_deref(), params.persist, true)
2536 }) {
2537 Ok(v) => v,
2538 Err(e) => return Self::err_content(e),
2539 };
2540
2541 let path = canonical;
2542 let engine_handle = self.engine_handle();
2543 let attachments = self.attachments_handle();
2544 let registry = self.watchers_handle();
2545 let options = crate::watcher::WatchOptions {
2546 max_concurrent: params.max_concurrent.unwrap_or(0) as usize,
2547 };
2548 let result = crate::watcher::start_watching(
2549 engine_handle,
2550 attachments,
2551 registry,
2552 Some(self.subscriptions_handle()),
2553 path.clone(),
2554 params.table.clone(),
2555 target_db,
2556 options,
2557 );
2558 match result {
2559 Ok(stats) => {
2560 let body = json!({
2561 "directory": path.to_string_lossy(),
2562 "table": params.table,
2563 "status": "watching",
2564 "max_concurrent": stats.max_concurrent,
2565 "initial_sweep": {
2566 "files_ingested": stats.files_ingested,
2567 "files_failed": stats.files_failed,
2568 },
2569 });
2570 Self::ok_content(body)
2571 }
2572 Err(e) => Self::err_content(e),
2573 }
2574 }
2575
2576 #[tool(
2578 description = "Stop watching a directory previously registered with watch_directory. Pending .ready files are left in place."
2579 )]
2580 fn unwatch_directory(
2581 &self,
2582 Parameters(params): Parameters<UnwatchDirectoryParams>,
2583 ) -> Result<CallToolResult, rmcp::ErrorData> {
2584 let path = std::path::PathBuf::from(¶ms.path);
2585 let result = crate::watcher::stop_watching(&self.watchers_handle(), &path);
2586 match result {
2587 Ok(summary) => Self::ok_content(summary),
2588 Err(e) => Self::err_content(e),
2589 }
2590 }
2591
2592 #[tool(
2595 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."
2596 )]
2597 fn describe(
2598 &self,
2599 Parameters(params): Parameters<DescribeParams>,
2600 ) -> Result<CallToolResult, rmcp::ErrorData> {
2601 let result = self.with_engine(|engine| {
2602 let target_db = self.resolve_db(engine, params.database.as_deref(), None, false)?;
2603 match params.table.as_deref() {
2604 Some(name) => engine
2605 .describe_table_in(target_db.as_deref(), name)
2606 .map(|t| vec![t]),
2607 None => engine.describe_tables_in(target_db.as_deref()),
2608 }
2609 });
2610
2611 match result {
2612 Ok(tables) => Self::ok_content(json!({"tables": tables})),
2613 Err(e) => Self::err_content(e),
2614 }
2615 }
2616
2617 #[tool(
2622 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)."
2623 )]
2624 #[expect(
2625 clippy::unused_self,
2626 reason = "method retained on the type for API symmetry; implementation currently does not need state"
2627 )]
2628 fn inspect_file(
2629 &self,
2630 Parameters(params): Parameters<InspectFileParams>,
2631 ) -> Result<CallToolResult, rmcp::ErrorData> {
2632 if let Err(e) = crate::attach::validate_input_path(¶ms.path, "data file") {
2633 return Self::err_content(e);
2634 }
2635 let sample_rows = params.sample_rows.unwrap_or(5).clamp(1, 50) as usize;
2636 let result = if let Some(ref json_path) = params.json_extract_path {
2637 (|| -> Result<_, McpError> {
2638 let raw = std::fs::read_to_string(¶ms.path).map_err(|e| {
2639 McpError::new(
2640 ErrorCode::FileNotFound,
2641 format!("Cannot read file '{}': {e}", params.path),
2642 )
2643 })?;
2644 let file_size = std::fs::metadata(¶ms.path).map_or(0, |m| m.len());
2645 let extracted = crate::ingest::extract_json_path(&raw, json_path)?;
2646 crate::inspect::inspect_json_from_text(&extracted, file_size, sample_rows)
2647 })()
2648 } else {
2649 crate::inspect::inspect_source(¶ms.path, sample_rows)
2650 };
2651 match result {
2652 Ok(report) => Self::ok_content(report.to_json()),
2653 Err(e) => Self::err_content(e),
2654 }
2655 }
2656
2657 #[tool(
2660 description = "Export query results or a table to a file via hyperd's native writers. Every format listed here is server-side — hyperd writes the file directly, with zero per-row work in the MCP process — and every format round-trips cleanly through the matching loader (`load_file` or `load_iceberg`).\n\nWhen choosing a format for *data leaving* Hyper, prefer in this order:\n 1. **Parquet** (recommended default): smallest output, fastest write, preserves every type (NUMERIC precision/scale, DATE, TIMESTAMP, etc.). `path` is a single file.\n 2. **Iceberg**: produces a full Apache Iceberg table directory (`metadata/` + `data/`). Use when the consumer is a data-lake tool (Spark, Trino, DuckDB, etc.). `path` is a directory that hyperd creates.\n 3. **Arrow IPC Stream** (`arrow_ipc`): same wire shape Hyper uses internally; great for handing data to another Arrow-aware process. Larger than Parquet (no compression) but extremely fast to read back. `path` is a single file.\n 4. **CSV**: portable and human-readable but the largest output and types are lost (everything becomes text). Use for spreadsheet / shell-pipeline interop. Includes header row.\n 5. **Hyper**: an entire `.hyper` database file openable directly in Tableau Desktop. `sql`/`table` are ignored — every user table is copied.\n\nAll formats except Iceberg and Hyper require either `sql` or `table`. Iceberg output is a directory; all others are single files.\n\nUse `database` to read from a non-primary source: for `format=\"hyper\"` it selects which database is snapshotted; for the row-oriented formats it routes the SELECT through the named database (when `table` is set) or pins `schema_search_path` for the call (when `sql` is set)."
2661 )]
2662 fn export(
2663 &self,
2664 Parameters(params): Parameters<ExportParams>,
2665 ) -> Result<CallToolResult, rmcp::ErrorData> {
2666 let result = self.with_engine(|engine| {
2667 crate::attach::validate_output_path(¶ms.path, "export")?;
2670 let format_options = match params.format_options.clone() {
2674 None => None,
2675 Some(Value::Object(m)) => Some(m),
2676 Some(other) => {
2677 return Err(McpError::new(
2678 ErrorCode::SchemaMismatch,
2679 format!("export: format_options must be a JSON object, got: {other}"),
2680 ));
2681 }
2682 };
2683 let target_db = self.resolve_db(engine, params.database.as_deref(), None, false)?;
2694 let (effective_sql, effective_table) = match (¶ms.sql, ¶ms.table, &target_db) {
2695 (None, Some(t), Some(db)) => {
2696 let esc_db = db.replace('"', "\"\"");
2697 let esc_tbl = t.replace('"', "\"\"");
2698 (
2699 Some(format!(
2700 "SELECT * FROM \"{esc_db}\".\"public\".\"{esc_tbl}\""
2701 )),
2702 None,
2703 )
2704 }
2705 _ => (params.sql.clone(), params.table.clone()),
2706 };
2707 let _search_guard = match (&effective_sql, &target_db, ¶ms.sql) {
2708 (Some(_), Some(alias), Some(_)) => Some(engine.scoped_search_path(alias)?),
2711 _ => None,
2712 };
2713 let opts = ExportOptions {
2714 sql: effective_sql,
2715 table: effective_table,
2716 path: params.path,
2717 format: params.format,
2718 overwrite: params.overwrite.unwrap_or(true),
2719 format_options,
2720 source_db: target_db.clone(),
2721 };
2722 let export_result = export_to_file(engine, &opts)?;
2723 Ok(json!({
2724 "output_path": export_result.stats.output_path,
2725 "rows": export_result.rows,
2726 "file_size_bytes": export_result.stats.file_size_bytes,
2727 "stats": export_result.stats.to_json(),
2728 }))
2729 });
2730
2731 match result {
2732 Ok(val) => Self::ok_content(val),
2733 Err(e) => Self::err_content(e),
2734 }
2735 }
2736
2737 #[tool(
2741 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."
2742 )]
2743 fn save_query(
2744 &self,
2745 Parameters(params): Parameters<SaveQueryParams>,
2746 ) -> Result<CallToolResult, rmcp::ErrorData> {
2747 if let Err(e) = self.check_writable("save_query") {
2748 return Self::err_content(e);
2749 }
2750 if !is_read_only_sql(¶ms.sql) {
2755 return Self::err_content(McpError::new(
2756 ErrorCode::SqlError,
2757 "save_query only accepts read-only SQL (SELECT / WITH / EXPLAIN / SHOW / VALUES). \
2758 Use the execute tool for DDL/DML, not save_query.",
2759 ));
2760 }
2761 if params.name.is_empty() {
2762 return Self::err_content(McpError::new(
2763 ErrorCode::SchemaMismatch,
2764 "Saved query name must not be empty.",
2765 ));
2766 }
2767 let query = SavedQuery {
2768 name: params.name.clone(),
2769 sql: params.sql,
2770 description: params.description,
2771 created_at: chrono::Utc::now(),
2772 };
2773 let store = Arc::clone(&self.saved_queries);
2774 let result = self.with_saved_query_store(|engine| store.save(engine, query.clone()));
2775 match result {
2776 Ok(()) => {
2777 self.notify_resource_list_changed();
2781 Self::ok_content(json!({
2782 "saved": true,
2783 "name": query.name,
2784 "resources": [
2785 format!("hyper://queries/{}/definition", query.name),
2786 format!("hyper://queries/{}/result", query.name),
2787 ],
2788 "created_at": query.created_at.to_rfc3339(),
2789 }))
2790 }
2791 Err(e) => Self::err_content(e),
2792 }
2793 }
2794
2795 #[tool(
2797 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)."
2798 )]
2799 fn delete_query(
2800 &self,
2801 Parameters(params): Parameters<DeleteQueryParams>,
2802 ) -> Result<CallToolResult, rmcp::ErrorData> {
2803 if let Err(e) = self.check_writable("delete_query") {
2804 return Self::err_content(e);
2805 }
2806 let store = Arc::clone(&self.saved_queries);
2807 let name = params.name.clone();
2808 let result = self.with_saved_query_store(|engine| store.delete(engine, &name));
2809 match result {
2810 Ok(deleted) => {
2811 if deleted {
2812 self.notify_resource_list_changed();
2817 self.subscriptions
2818 .notify_updated(&format!("hyper://queries/{name}/definition"));
2819 self.subscriptions
2820 .notify_updated(&format!("hyper://queries/{name}/result"));
2821 }
2822 Self::ok_content(json!({
2823 "deleted": deleted,
2824 "name": params.name,
2825 }))
2826 }
2827 Err(e) => Self::err_content(e),
2828 }
2829 }
2830
2831 #[tool(
2833 description = "Update prose metadata for a table in the `_table_catalog`: source_url, source_description, purpose, license, notes. Fields you omit stay unchanged; pass an explicit empty string (\"\") to clear a field. Mechanical fields (load_tool, load_params, loaded_at, last_refreshed_at, row_count) are managed by the server. Requires an existing catalog entry — load the table first (load_file / load_data / execute CREATE TABLE) so the stub row is created automatically. Use `database` to target the metadata for a table in a non-primary writable database; read-only attachments are rejected with a clear re-attach-with-writable message. Disabled in read-only mode."
2834 )]
2835 fn set_table_metadata(
2836 &self,
2837 Parameters(params): Parameters<SetTableMetadataParams>,
2838 ) -> Result<CallToolResult, rmcp::ErrorData> {
2839 if let Err(e) = self.check_writable("set_table_metadata") {
2840 return Self::err_content(e);
2841 }
2842 let fields = crate::table_catalog::MetadataFields {
2843 source_url: params.source_url,
2844 source_description: params.source_description,
2845 purpose: params.purpose,
2846 license: params.license,
2847 notes: params.notes,
2848 };
2849 let table_name = params.table.clone();
2850 let result = self.with_engine(|engine| {
2851 let target_db = self.resolve_db(engine, params.database.as_deref(), None, true)?;
2857 crate::table_catalog::set_metadata_in(
2858 engine,
2859 &table_name,
2860 &fields,
2861 target_db.as_deref(),
2862 )
2863 });
2864 match result {
2865 Ok(entry) => Self::ok_content(entry.to_json()),
2866 Err(e) => Self::err_content(e),
2867 }
2868 }
2869
2870 #[tool(
2873 description = "Returns plugin health, workspace info, table count, total rows, disk usage, and active directory watchers."
2874 )]
2875 fn status(&self) -> Result<CallToolResult, rmcp::ErrorData> {
2876 let result = self.with_engine(super::engine::Engine::status);
2877
2878 match result {
2879 Ok(mut val) => {
2880 if let Some(obj) = val.as_object_mut() {
2881 obj.insert("watchers".into(), self.watchers.to_json());
2882 obj.insert("read_only".into(), json!(self.read_only));
2883 let attachments: Vec<Value> = self
2884 .attachments
2885 .list()
2886 .iter()
2887 .map(super::attach::AttachedDb::to_json)
2888 .collect();
2889 obj.insert("attachments".into(), Value::Array(attachments));
2890 }
2891 Self::ok_content(val)
2892 }
2893 Err(e) => Self::err_content(e),
2894 }
2895 }
2896
2897 #[tool(
2902 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."
2903 )]
2904 #[expect(
2905 clippy::unused_self,
2906 reason = "the #[tool] macro dispatches on &self; signature must match the rest of the tool surface even though this tool is stateless"
2907 )]
2908 #[expect(
2909 clippy::unnecessary_wraps,
2910 reason = "uniform Result<CallToolResult, rmcp::ErrorData> across all tools so the #[tool_router] dispatcher has one signature shape"
2911 )]
2912 fn get_readme(&self) -> Result<CallToolResult, rmcp::ErrorData> {
2913 Ok(CallToolResult::success(vec![Content::text(
2914 crate::readme::README,
2915 )]))
2916 }
2917
2918 #[tool(
2921 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."
2922 )]
2923 fn attach_database(
2924 &self,
2925 Parameters(params): Parameters<AttachDatabaseParams>,
2926 ) -> Result<CallToolResult, rmcp::ErrorData> {
2927 let writable = params.writable.unwrap_or(false);
2928 if writable {
2929 if let Err(e) = self.check_writable("attach_database(writable)") {
2930 return Self::err_content(e);
2931 }
2932 }
2933 let on_missing = match attach::OnMissing::parse(params.on_missing.as_deref()) {
2934 Ok(v) => v,
2935 Err(e) => return Self::err_content(e),
2936 };
2937 if on_missing == attach::OnMissing::Create && !writable {
2938 return Self::err_content(McpError::new(
2939 ErrorCode::InvalidArgument,
2940 "on_missing='create' requires writable:true — an empty .hyper file that cannot be written to cannot be populated.",
2941 ));
2942 }
2943 let source = match params.kind.as_str() {
2944 "local_file" => {
2945 let Some(raw) = params.path.as_deref() else {
2946 return Self::err_content(McpError::new(
2947 ErrorCode::InvalidArgument,
2948 "kind='local_file' requires a 'path' argument",
2949 ));
2950 };
2951 let resolved = match on_missing {
2952 attach::OnMissing::Error => attach::validate_local_path(raw),
2953 attach::OnMissing::Create => attach::validate_local_path_for_create(raw),
2954 };
2955 match resolved {
2956 Ok(canonical) => AttachSource::LocalFile { path: canonical },
2957 Err(e) => return Self::err_content(e),
2958 }
2959 }
2960 other => {
2961 return Self::err_content(McpError::new(
2962 ErrorCode::InvalidArgument,
2963 format!(
2964 "Unsupported attach kind '{other}'. Only 'local_file' is supported today; \
2965 'tcp' (remote hyperd) and 'grpc' (Data 360) are planned."
2966 ),
2967 ));
2968 }
2969 };
2970 let req = AttachRequest {
2971 alias: params.alias.clone(),
2972 source,
2973 writable,
2974 on_missing,
2975 };
2976 let registry = self.attachments_handle();
2977 let alias_for_probe = req.alias.clone();
2978 let result = self.with_engine(|engine| {
2979 let entry = registry.attach(engine, req.clone())?;
2980 let tables_visible = probe_table_count(engine, &alias_for_probe);
2985 Ok(json!({
2986 "alias": entry.alias,
2987 "kind": entry.source.kind_str(),
2988 "source": entry.source.to_json(),
2989 "writable": entry.writable,
2990 "tables_visible": tables_visible,
2991 }))
2992 });
2993 match result {
2994 Ok(val) => Self::ok_content(val),
2995 Err(e) => Self::err_content(e),
2996 }
2997 }
2998
2999 #[tool(
3001 description = "Detach a database previously registered with attach_database. No-op when the alias is unknown. Returns {detached: true/false}."
3002 )]
3003 fn detach_database(
3004 &self,
3005 Parameters(params): Parameters<DetachDatabaseParams>,
3006 ) -> Result<CallToolResult, rmcp::ErrorData> {
3007 let alias = params.alias.to_ascii_lowercase();
3012 if let Ok(watchers) = self.watchers.watchers.lock() {
3018 let conflict = watchers
3019 .values()
3020 .find(|h| h.target_db.as_deref() == Some(alias.as_str()));
3021 if let Some(h) = conflict {
3022 return Self::err_content(McpError::new(
3023 ErrorCode::InvalidArgument,
3024 format!(
3025 "cannot detach '{alias}': an active watcher on directory '{}' targets it. \
3026 Call unwatch_directory(\"{}\") first.",
3027 h.directory.display(),
3028 h.directory.display()
3029 ),
3030 ));
3031 }
3032 }
3033 let registry = self.attachments_handle();
3034 let result = self.with_engine(|engine| {
3035 let outcome = registry.detach(engine, &alias)?;
3036 if outcome {
3037 engine.clear_catalog_cache_for(&alias);
3041 }
3042 Ok(outcome)
3043 });
3044 match result {
3045 Ok(detached) => {
3046 Self::ok_content(json!({ "alias": params.alias, "detached": detached }))
3047 }
3048 Err(e) => Self::err_content(e),
3049 }
3050 }
3051
3052 #[tool(
3062 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."
3063 )]
3064 fn list_attached_databases(&self) -> Result<CallToolResult, rmcp::ErrorData> {
3065 let result = self.with_engine(|engine| {
3066 let entries = self.attachments.list();
3067 let attachments: Vec<Value> = entries
3068 .iter()
3069 .map(|entry| {
3070 let mut obj = entry.to_json();
3071 let tables_visible = probe_table_count(engine, &entry.alias);
3072 if let Some(map) = obj.as_object_mut() {
3073 map.insert("tables_visible".into(), json!(tables_visible));
3074 }
3075 obj
3076 })
3077 .collect();
3078 Ok(json!({ "attachments": attachments }))
3079 });
3080 match result {
3081 Ok(val) => Self::ok_content(val),
3082 Err(e) => Self::err_content(e),
3083 }
3084 }
3085
3086 #[tool(
3091 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."
3092 )]
3093 fn copy_query(
3094 &self,
3095 Parameters(params): Parameters<CopyQueryParams>,
3096 ) -> Result<CallToolResult, rmcp::ErrorData> {
3097 if let Err(e) = self.check_writable("copy_query") {
3098 return Self::err_content(e);
3099 }
3100 let mode = match params.mode.as_str() {
3101 "create" | "append" | "replace" => params.mode.clone(),
3102 other => {
3103 return Self::err_content(McpError::new(
3104 ErrorCode::InvalidArgument,
3105 format!(
3106 "copy_query mode '{other}' is not supported. Use 'create', 'append', or 'replace'."
3107 ),
3108 ));
3109 }
3110 };
3111 if !is_read_only_sql(¶ms.sql) {
3112 return Self::err_content(McpError::new(
3113 ErrorCode::SqlError,
3114 "copy_query's `sql` must be a read-only statement (SELECT / WITH / VALUES). \
3115 Use the execute tool for raw DDL/DML.",
3116 ));
3117 }
3118 let target_db_owned = params
3130 .target_database
3131 .as_deref()
3132 .filter(|s| !s.eq_ignore_ascii_case(LOCAL_ALIAS))
3133 .map(str::to_ascii_lowercase);
3134 let target_db = target_db_owned.as_deref();
3135 if let Some(alias) = target_db {
3136 match self.attachments.get(alias) {
3137 None => {
3138 return Self::err_content(McpError::new(
3139 ErrorCode::InvalidArgument,
3140 format!(
3141 "target_database '{alias}' is not attached. Call attach_database first."
3142 ),
3143 ));
3144 }
3145 Some(entry) if !entry.writable => {
3146 return Self::err_content(McpError::new(
3147 ErrorCode::InvalidArgument,
3148 format!(
3149 "target_database '{alias}' was attached read-only. Re-attach with writable:true to use it as a copy target."
3150 ),
3151 ));
3152 }
3153 Some(_) => {}
3154 }
3155 }
3156
3157 let temp_specs = params.temp_attach.clone().unwrap_or_default();
3160 let prepared_temps = match prepare_temp_attachments(&temp_specs, self.is_read_only()) {
3161 Ok(v) => v,
3162 Err(e) => return Self::err_content(e),
3163 };
3164
3165 let target_table = params.target_table.clone();
3166 let sql_body = params.sql.clone();
3167 let load_params = serde_json::to_string(&json!({
3168 "mode": mode,
3169 "target_database": params.target_database,
3170 "target_table": target_table,
3171 "sql": Self::fmt_sql(&sql_body),
3172 }))
3173 .ok();
3174
3175 let registry = self.attachments_handle();
3176 let result = self.with_engine(|engine| {
3177 let mut temp_aliases: Vec<String> = Vec::new();
3179 for req in &prepared_temps {
3180 match registry.attach(engine, req.clone()) {
3181 Ok(entry) => temp_aliases.push(entry.alias),
3182 Err(e) => {
3183 for alias in &temp_aliases {
3185 let _ = registry.detach(engine, alias);
3186 }
3187 return Err(e);
3188 }
3189 }
3190 }
3191
3192 let copy_outcome = perform_copy(engine, &mode, target_db, &target_table, &sql_body);
3195
3196 for alias in &temp_aliases {
3200 if let Err(e) = registry.detach(engine, alias) {
3201 tracing::warn!(
3202 alias = %alias,
3203 err = %e.message,
3204 "failed to detach temp attachment after copy_query",
3205 );
3206 }
3207 }
3208
3209 if copy_outcome.is_ok() && target_db.is_none() {
3220 let row_count = copy_outcome
3221 .as_ref()
3222 .ok()
3223 .and_then(|v| v.get("row_count").and_then(serde_json::Value::as_i64));
3224 self.after_ingest_catalog_update(
3225 engine,
3226 &target_table,
3227 "copy_query",
3228 load_params.as_deref(),
3229 row_count,
3230 target_db,
3231 );
3232 }
3233
3234 copy_outcome
3235 });
3236
3237 match result {
3238 Ok(outcome) => {
3239 if target_db.is_none() {
3241 self.notify_table_changed(&target_table);
3242 }
3243 self.notify_workspace_changed();
3244 if mode != "append" {
3245 self.notify_resource_list_changed();
3248 }
3249 Self::ok_content(outcome)
3250 }
3251 Err(e) => Self::err_content(e),
3252 }
3253 }
3254}
3255
3256#[prompt_router]
3259impl HyperMcpServer {
3260 #[prompt(
3262 name = "analyze-table",
3263 description = "Deep analysis of a single table: schema, sample, column stats, data quality"
3264 )]
3265 pub async fn analyze_table(
3266 &self,
3267 Parameters(args): Parameters<AnalyzeTableArgs>,
3268 ) -> Vec<PromptMessage> {
3269 let context = self.build_analyze_context(&args.table);
3270 vec![
3271 PromptMessage::new_text(
3272 PromptMessageRole::User,
3273 format!(
3274 "Analyze the `{}` table thoroughly.\n\n{}\n\nPlease:\n\
3275 1. Describe each column (what it likely represents based on name and sample values)\n\
3276 2. Compute basic statistics using the query tool: min/max/avg for numeric columns, distinct count and top values for text columns\n\
3277 3. Flag any data quality issues: unexpected NULLs, suspicious outliers, inconsistent formats\n\
3278 4. Summarize your findings in plain English",
3279 args.table, context
3280 ),
3281 ),
3282 PromptMessage::new_text(
3283 PromptMessageRole::Assistant,
3284 format!(
3285 "I'll analyze the `{}` table systematically. Let me start by examining the schema and sample, then run targeted queries for statistics and data quality.",
3286 args.table
3287 ),
3288 ),
3289 ]
3290 }
3291
3292 #[prompt(
3294 name = "compare-tables",
3295 description = "Compare two tables: schema alignment, common keys, JOIN opportunities"
3296 )]
3297 pub async fn compare_tables(
3298 &self,
3299 Parameters(args): Parameters<CompareTablesArgs>,
3300 ) -> Vec<PromptMessage> {
3301 let ctx_a = self.build_brief_context(&args.table_a);
3302 let ctx_b = self.build_brief_context(&args.table_b);
3303 vec![
3304 PromptMessage::new_text(
3305 PromptMessageRole::User,
3306 format!(
3307 "Compare these two tables:\n\n## Table A: `{}`\n{}\n\n## Table B: `{}`\n{}\n\nPlease:\n\
3308 1. Identify columns that appear in both tables (by name or semantic match)\n\
3309 2. Suggest likely JOIN keys and the JOIN type (inner, left, etc.)\n\
3310 3. Highlight schema differences (column types, nullability)\n\
3311 4. Propose 3-5 analytical queries that combine both tables and explain what each reveals",
3312 args.table_a, ctx_a, args.table_b, ctx_b
3313 ),
3314 ),
3315 PromptMessage::new_text(
3316 PromptMessageRole::Assistant,
3317 format!(
3318 "I'll compare `{}` and `{}` systematically — schema alignment first, then join keys, then analytical opportunities.",
3319 args.table_a, args.table_b
3320 ),
3321 ),
3322 ]
3323 }
3324
3325 #[prompt(
3327 name = "data-quality",
3328 description = "Systematic data quality assessment: NULL rates, duplicates, low cardinality, outliers"
3329 )]
3330 pub async fn data_quality(
3331 &self,
3332 Parameters(args): Parameters<DataQualityArgs>,
3333 ) -> Vec<PromptMessage> {
3334 let context = self.build_brief_context(&args.table);
3335 vec![
3336 PromptMessage::new_text(
3337 PromptMessageRole::User,
3338 format!(
3339 "Run a data quality assessment on the `{}` table.\n\n{}\n\nPlease use the query tool to check:\n\
3340 1. NULL rate per column — run SELECT COUNT(*) FILTER (WHERE col IS NULL) / COUNT(*) for each column\n\
3341 2. Duplicate rows — compare COUNT(*) vs COUNT(DISTINCT *) or use GROUP BY\n\
3342 3. Low-cardinality columns — columns with suspiciously few distinct values\n\
3343 4. Numeric outliers — values more than 3 stddev from the mean\n\
3344 5. Date sanity — future dates or impossibly old dates in date/timestamp columns\n\n\
3345 Summarize findings with severity (critical / warning / info) and suggest remediation for each issue.",
3346 args.table, context
3347 ),
3348 ),
3349 PromptMessage::new_text(
3350 PromptMessageRole::Assistant,
3351 format!(
3352 "I'll perform a systematic data quality assessment on `{}`. Let me run targeted queries for each check category.",
3353 args.table
3354 ),
3355 ),
3356 ]
3357 }
3358
3359 #[prompt(
3361 name = "suggest-queries",
3362 description = "Suggest analytical SQL queries for a table, optionally guided by a goal"
3363 )]
3364 pub async fn suggest_queries(
3365 &self,
3366 Parameters(args): Parameters<SuggestQueriesArgs>,
3367 ) -> Vec<PromptMessage> {
3368 let context = self.build_analyze_context(&args.table);
3369 let goal_section = match args.goal.as_deref() {
3370 Some(g) if !g.is_empty() => format!("\n\nSpecific goal: {g}"),
3371 _ => String::new(),
3372 };
3373 vec![
3374 PromptMessage::new_text(
3375 PromptMessageRole::User,
3376 format!(
3377 "Given the `{}` table:\n\n{}{}\n\nSuggest 5 analytical SQL queries that would be useful for exploring this data. \
3378 For each query, provide:\n\
3379 - A descriptive title\n\
3380 - The exact SQL (valid for Hyper / PostgreSQL-compatible syntax)\n\
3381 - One sentence explaining what insight it reveals\n\n\
3382 Prefer queries that use aggregations, GROUP BY, window functions, or CTEs to demonstrate the power of SQL analytics.",
3383 args.table, context, goal_section
3384 ),
3385 ),
3386 PromptMessage::new_text(
3387 PromptMessageRole::Assistant,
3388 format!(
3389 "Based on the schema and sample of `{}`, here are 5 analytical queries.",
3390 args.table
3391 ),
3392 ),
3393 ]
3394 }
3395}
3396
3397#[derive(Debug, Clone)]
3406pub enum ResourceBody {
3407 Json(Value),
3409 Text {
3412 mime_type: String,
3414 content: String,
3416 },
3417}
3418
3419impl ResourceBody {
3420 #[must_use]
3422 pub fn mime_type(&self) -> &str {
3423 match self {
3424 ResourceBody::Json(_) => "application/json",
3425 ResourceBody::Text { mime_type, .. } => mime_type,
3426 }
3427 }
3428
3429 #[must_use]
3432 pub fn to_text(&self) -> String {
3433 match self {
3434 ResourceBody::Json(v) => {
3435 serde_json::to_string_pretty(v).unwrap_or_else(|_| v.to_string())
3436 }
3437 ResourceBody::Text { content, .. } => content.clone(),
3438 }
3439 }
3440
3441 #[must_use]
3444 pub fn as_json(&self) -> Option<&Value> {
3445 match self {
3446 ResourceBody::Json(v) => Some(v),
3447 ResourceBody::Text { .. } => None,
3448 }
3449 }
3450}
3451
3452impl HyperMcpServer {
3453 pub fn resource_body_for_uri(&self, uri: &str) -> Result<Option<ResourceBody>, McpError> {
3472 if uri == "hyper://workspace" {
3473 return self
3474 .with_engine(super::engine::Engine::status)
3475 .map(|v| Some(ResourceBody::Json(v)));
3476 }
3477 if uri == "hyper://tables" {
3478 return self
3479 .with_engine(|engine| {
3480 engine
3481 .describe_tables()
3482 .map(|tables| json!({ "tables": tables }))
3483 })
3484 .map(|v| Some(ResourceBody::Json(v)));
3485 }
3486 if uri == "hyper://readme" {
3487 return self.build_readme_body().map(Some);
3488 }
3489 if let Some(name) = uri
3490 .strip_prefix("hyper://tables/")
3491 .and_then(|rest| rest.strip_suffix("/schema"))
3492 {
3493 let name = name.to_string();
3494 return self
3495 .with_engine(|engine| {
3496 let tables = engine.describe_tables()?;
3497 tables
3498 .into_iter()
3499 .find(|t| t.get("name").and_then(|v| v.as_str()) == Some(name.as_str()))
3500 .ok_or_else(|| {
3501 McpError::new(
3502 ErrorCode::TableNotFound,
3503 format!("Table '{name}' does not exist"),
3504 )
3505 })
3506 })
3507 .map(|v| Some(ResourceBody::Json(v)));
3508 }
3509 if let Some(name) = uri
3510 .strip_prefix("hyper://tables/")
3511 .and_then(|rest| rest.strip_suffix("/sample"))
3512 {
3513 let name = name.to_string();
3514 return self
3515 .with_engine(|engine| engine.sample_table(&name, TABLE_SAMPLE_ROWS))
3516 .map(|v| Some(ResourceBody::Json(v)));
3517 }
3518 if let Some(name) = uri
3519 .strip_prefix("hyper://tables/")
3520 .and_then(|rest| rest.strip_suffix("/csv-sample"))
3521 {
3522 let name = name.to_string();
3523 return self.build_csv_sample_body(&name).map(Some);
3524 }
3525 if let Some(name) = uri
3526 .strip_prefix("hyper://queries/")
3527 .and_then(|rest| rest.strip_suffix("/definition"))
3528 {
3529 return self.build_saved_query_definition(name).map(Some);
3530 }
3531 if let Some(name) = uri
3532 .strip_prefix("hyper://queries/")
3533 .and_then(|rest| rest.strip_suffix("/result"))
3534 {
3535 return self.build_saved_query_result(name).map(Some);
3536 }
3537 Ok(None)
3538 }
3539
3540 fn build_saved_query_definition(&self, name: &str) -> Result<ResourceBody, McpError> {
3544 let store = Arc::clone(&self.saved_queries);
3545 let name = name.to_string();
3546 let query = self.with_saved_query_store(|engine| store.get(engine, &name))?;
3547 match query {
3548 Some(q) => Ok(ResourceBody::Json(q.to_json())),
3549 None => Err(McpError::new(
3550 ErrorCode::TableNotFound,
3551 format!("No saved query named '{name}'"),
3552 )),
3553 }
3554 }
3555
3556 fn build_saved_query_result(&self, name: &str) -> Result<ResourceBody, McpError> {
3561 let store = Arc::clone(&self.saved_queries);
3562 let name_owned = name.to_string();
3563 let query = self
3564 .with_saved_query_store(|engine| store.get(engine, &name_owned))?
3565 .ok_or_else(|| {
3566 McpError::new(
3567 ErrorCode::TableNotFound,
3568 format!("No saved query named '{name_owned}'"),
3569 )
3570 })?;
3571 let sql = query.sql.clone();
3572 let body = self.with_engine(|engine| {
3573 let timer = crate::stats::StatsTimer::start();
3574 let rows = engine.execute_query_to_json(&sql)?;
3575 let elapsed = timer.elapsed_ms();
3576 let result_size = serde_json::to_string(&rows).map_or(0, |s| s.len() as u64);
3577 let stats = crate::stats::QueryStats {
3578 operation: "saved_query".into(),
3579 rows_returned: rows.len() as u64,
3580 rows_scanned: 0,
3581 elapsed_ms: elapsed,
3582 result_size_bytes: result_size,
3583 tables_touched: vec![],
3584 };
3585 Ok(json!({
3586 "name": query.name,
3587 "sql": Self::fmt_sql(&query.sql),
3588 "result": rows,
3589 "stats": stats.to_json(),
3590 }))
3591 })?;
3592 Ok(ResourceBody::Json(body))
3593 }
3594
3595 #[must_use]
3602 pub fn list_resource_uris(&self) -> Vec<String> {
3603 let mut uris = vec![
3604 "hyper://workspace".to_string(),
3605 "hyper://tables".to_string(),
3606 "hyper://readme".to_string(),
3607 ];
3608 if let Ok(tables) = self.with_engine(super::engine::Engine::describe_tables) {
3609 for table in tables {
3613 if let Some(name) = table.get("name").and_then(|v| v.as_str()) {
3614 uris.push(format!("hyper://tables/{name}/schema"));
3615 uris.push(format!("hyper://tables/{name}/sample"));
3616 uris.push(format!("hyper://tables/{name}/csv-sample"));
3617 }
3618 }
3619 }
3620 let store = Arc::clone(&self.saved_queries);
3621 if let Ok(saved) = self.with_saved_query_store(|engine| store.list(engine)) {
3622 for q in saved {
3623 uris.push(format!("hyper://queries/{}/definition", q.name));
3624 uris.push(format!("hyper://queries/{}/result", q.name));
3625 }
3626 }
3627 uris
3628 }
3629
3630 fn build_readme_body(&self) -> Result<ResourceBody, McpError> {
3638 let status = self.with_engine(super::engine::Engine::status)?;
3639 let tables = self
3640 .with_engine(super::engine::Engine::describe_tables)
3641 .unwrap_or_default();
3642
3643 let workspace_mode = status
3644 .get("workspace_mode")
3645 .and_then(|v| v.as_str())
3646 .unwrap_or("unknown");
3647 let workspace_path = status
3648 .get("workspace_path")
3649 .and_then(|v| v.as_str())
3650 .unwrap_or("");
3651 let read_only = status
3652 .get("read_only")
3653 .and_then(serde_json::Value::as_bool)
3654 .unwrap_or(false);
3655 let table_count = tables.len();
3656
3657 let mut md = String::new();
3658 md.push_str("# HyperDB workspace\n\n");
3659 let _ = writeln!(
3660 md,
3661 "- Mode: **{workspace_mode}**{}\n",
3662 if read_only { " (read-only)" } else { "" }
3663 );
3664 if !workspace_path.is_empty() {
3665 let _ = writeln!(md, "- Path: `{workspace_path}`\n");
3666 }
3667 let _ = write!(md, "- Tables: **{table_count}**\n\n");
3668
3669 if tables.is_empty() {
3670 md.push_str(
3671 "_No tables loaded yet._ Use the `load_file` or `load_data` tools to \
3672 ingest CSV / JSON / Parquet / Arrow IPC data; call `inspect_file` \
3673 first if you're unsure of the schema.\n",
3674 );
3675 } else {
3676 md.push_str("## Tables\n\n");
3677 md.push_str("| Table | Rows | Columns |\n");
3678 md.push_str("|---|---:|---|\n");
3679 for t in &tables {
3680 let name = t.get("name").and_then(|v| v.as_str()).unwrap_or("?");
3681 let rows = t
3682 .get("row_count")
3683 .and_then(serde_json::Value::as_i64)
3684 .unwrap_or(0);
3685 let cols: Vec<String> = t
3686 .get("columns")
3687 .and_then(|v| v.as_array())
3688 .map(|arr| {
3689 arr.iter()
3690 .filter_map(|c| {
3691 let n = c.get("name")?.as_str()?;
3692 let ty = c.get("type")?.as_str()?;
3693 Some(format!("`{n}` {ty}"))
3694 })
3695 .collect()
3696 })
3697 .unwrap_or_default();
3698 let _ = writeln!(md, "| `{name}` | {rows} | {} |\n", cols.join(", "));
3699 }
3700 md.push('\n');
3701 md.push_str("## Related resources\n\n");
3702 for t in &tables {
3703 if let Some(name) = t.get("name").and_then(|v| v.as_str()) {
3704 let _ = write!(md, "- `hyper://tables/{name}/schema` — JSON schema and row count\n\
3705 - `hyper://tables/{name}/sample` — first {TABLE_SAMPLE_ROWS} rows as JSON\n\
3706 - `hyper://tables/{name}/csv-sample` — first {TABLE_CSV_SAMPLE_ROWS} rows as CSV\n");
3707 }
3708 }
3709 md.push('\n');
3710 }
3711
3712 md.push_str(
3713 "## Tool hints\n\n\
3714 - `query(sql)` — read-only SQL (SELECT / WITH / EXPLAIN / SHOW / VALUES).\n\
3715 - `execute(sql)` — DDL/DML (disabled in read-only mode).\n\
3716 - `sample(table, n)` — configurable row sample; the fixed-size\n \
3717 `hyper://tables/{name}/sample` resource uses n=5.\n\
3718 - `inspect_file(path)` — dry-run schema inference before loading.\n\
3719 - `chart(sql, chart_type, ...)` — render a PNG/SVG from a query.\n\
3720 - `export(sql|table, path, format)` — write to CSV / Parquet / Arrow IPC / .hyper.\n",
3721 );
3722
3723 Ok(ResourceBody::Text {
3724 mime_type: "text/markdown".into(),
3725 content: md,
3726 })
3727 }
3728
3729 fn build_csv_sample_body(&self, table: &str) -> Result<ResourceBody, McpError> {
3733 let sample =
3734 self.with_engine(|engine| engine.sample_table(table, TABLE_CSV_SAMPLE_ROWS))?;
3735
3736 let header: Vec<String> = sample
3740 .get("schema")
3741 .and_then(|v| v.as_array())
3742 .map(|cols| {
3743 cols.iter()
3744 .filter_map(|c| c.get("name").and_then(|n| n.as_str()).map(String::from))
3745 .collect()
3746 })
3747 .filter(|v: &Vec<String>| !v.is_empty())
3748 .or_else(|| {
3749 sample
3750 .get("rows")
3751 .and_then(|v| v.as_array())
3752 .and_then(|rows| rows.first())
3753 .and_then(|r| r.as_object())
3754 .map(|o| o.keys().cloned().collect())
3755 })
3756 .unwrap_or_default();
3757
3758 let mut wtr = csv::Writer::from_writer(Vec::<u8>::new());
3759 if !header.is_empty() {
3760 wtr.write_record(&header).map_err(|e| {
3761 McpError::new(
3762 ErrorCode::InternalError,
3763 format!("Failed to write CSV header: {e}"),
3764 )
3765 })?;
3766 }
3767 if let Some(rows) = sample.get("rows").and_then(|v| v.as_array()) {
3768 for row in rows {
3769 let record: Vec<String> = header
3770 .iter()
3771 .map(|col| row.get(col).map(value_to_csv_cell).unwrap_or_default())
3772 .collect();
3773 wtr.write_record(&record).map_err(|e| {
3774 McpError::new(
3775 ErrorCode::InternalError,
3776 format!("Failed to write CSV row: {e}"),
3777 )
3778 })?;
3779 }
3780 }
3781 let bytes = wtr.into_inner().map_err(|e| {
3782 McpError::new(
3783 ErrorCode::InternalError,
3784 format!("Failed to finalize CSV: {e}"),
3785 )
3786 })?;
3787 let content = String::from_utf8(bytes).map_err(|e| {
3788 McpError::new(
3789 ErrorCode::InternalError,
3790 format!("CSV produced invalid UTF-8: {e}"),
3791 )
3792 })?;
3793
3794 Ok(ResourceBody::Text {
3795 mime_type: "text/csv".into(),
3796 content,
3797 })
3798 }
3799
3800 fn build_analyze_context(&self, table: &str) -> String {
3803 match self.with_engine(|engine| engine.sample_table(table, 10)) {
3804 Ok(sample) => format!(
3805 "Schema and sample:\n```json\n{}\n```",
3806 serde_json::to_string_pretty(&sample).unwrap_or_else(|_| sample.to_string())
3807 ),
3808 Err(e) => format!("(Could not load table context: {e})"),
3809 }
3810 }
3811
3812 fn build_brief_context(&self, table: &str) -> String {
3814 match self.with_engine(|engine| engine.sample_table(table, 5)) {
3815 Ok(sample) => format!(
3816 "```json\n{}\n```",
3817 serde_json::to_string_pretty(&sample).unwrap_or_else(|_| sample.to_string())
3818 ),
3819 Err(e) => format!("(Could not load table context: {e})"),
3820 }
3821 }
3822}
3823
3824#[tool_handler]
3827#[prompt_handler]
3828impl ServerHandler for HyperMcpServer {
3829 fn get_info(&self) -> ServerInfo {
3830 let sql_dialect = "\n\
3831\n\
3832SQL DIALECT — Salesforce Data Cloud SQL (PostgreSQL-compatible with extensions).\n\
3833Key differences from standard PostgreSQL an LLM should know:\n\
3834\n\
3835TYPES\n\
3836- Supported: SMALLINT, INTEGER/INT, BIGINT, REAL/FLOAT4, DOUBLE PRECISION/FLOAT8,\n\
3837 NUMERIC(p,s)/DECIMAL(p,s), BOOLEAN, TEXT, CHAR(n), VARCHAR(n), BYTES,\n\
3838 DATE, TIME, TIMESTAMP, TIMESTAMPTZ, INTERVAL, and arrays of any atomic type\n\
3839- NUMERIC precision > 18 requires .hyper file format version 3 (default in this MCP)\n\
3840- No SERIAL / BIGSERIAL / UUID / JSON / JSONB / geometry types\n\
3841\n\
3842SELECT / QUERY\n\
3843- LIMIT / OFFSET work as in PostgreSQL; TOP N is also accepted\n\
3844- LATERAL is optional: subqueries in FROM always see preceding FROM items implicitly\n\
3845- DISTINCT ON (expr, ...) is supported\n\
3846- FROM clause is optional (can evaluate expressions without a table)\n\
3847- Function calls may appear directly in the FROM list\n\
3848- information_schema and pg_catalog do NOT exist; use the describe/sample tools\n\
3849\n\
3850GROUP BY / AGGREGATION\n\
3851- GROUPING SETS, ROLLUP, CUBE all supported\n\
3852- GROUP BY DISTINCT removes duplicate grouping sets before processing\n\
3853- FILTER (WHERE ...) clause supported on aggregate calls\n\
3854- Ordered-set aggregates: MODE(), PERCENTILE_CONT(), PERCENTILE_DISC() with WITHIN GROUP (ORDER BY ...)\n\
3855- APPROX_COUNT_DISTINCT() for fast approximate cardinality\n\
3856- GROUPING() function identifies which columns are aggregated in GROUPING SETS\n\
3857\n\
3858WINDOW FUNCTIONS\n\
3859- Standard: row_number, rank, dense_rank, percent_rank, cume_dist, ntile, lag, lead,\n\
3860 first_value, last_value, nth_value\n\
3861- Hyper extension: modified_rank() — like rank() but assigns the LOWEST rank on ties\n\
3862- IGNORE NULLS / RESPECT NULLS supported on last_value only\n\
3863- nth_value supports FROM FIRST / FROM LAST\n\
3864- Frame modes: ROWS, RANGE, GROUPS; EXCLUDE CURRENT ROW / GROUP / TIES / NO OTHERS\n\
3865- Window-specific functions do NOT support DISTINCT or ORDER BY in their argument list\n\
3866\n\
3867SET-RETURNING FUNCTIONS (usable in FROM)\n\
3868- unnest(array) — expands an array to rows; supports WITH ORDINALITY\n\
3869- generate_series(start, stop [, step]) — numeric and datetime variants\n\
3870- external(path, format => '...') — reads Parquet, CSV, Iceberg etc. directly from files\n\
3871\n\
3872SET OPERATORS\n\
3873- UNION, INTERSECT, EXCEPT all supported; INTERSECT binds tighter than UNION/EXCEPT\n\
3874- ORDER BY and LIMIT/OFFSET can appear on parenthesized sub-expressions or the final result\n\
3875\n\
3876CTEs\n\
3877- WITH and WITH RECURSIVE both supported\n\
3878- CTEs evaluate once per query execution even if referenced multiple times\n\
3879\n\
3880IDENTIFIERS\n\
3881- Unquoted identifiers are folded to lowercase; double-quote to preserve case or use special chars\n\
3882- Quote names containing uppercase letters, digits at the start, or special characters\n\
3883\n\
3884NOT AVAILABLE IN HYPER (Data 360 / Data Cloud-only features)\n\
3885- AI functions: AI_CLASSIFY, AI_SENTIMENT, and other Data Cloud AI scalar functions\n\
3886- Data Cloud federation / streaming-specific functions\n\
3887\n\
3888Full SQL reference: https://developer.salesforce.com/docs/data/data-cloud-query-guide/references/dc-sql-reference";
3889
3890 let header = if self.read_only {
3891 "HyperDB MCP (read-only): SQL analytics for LLM workflows. Query existing tables, \
3892 sample data, export results. Mutating operations are disabled. \
3893 Call get_readme for a concise tool index, parameter rules, and usage examples."
3894 } else {
3895 "HyperDB MCP: instant SQL analytics for LLM workflows. Load data (CSV, JSON, Parquet, \
3896 Arrow IPC, Apache Iceberg), query with SQL, export results (Parquet, Iceberg, Arrow IPC, \
3897 CSV, Hyper). Use query for SELECT and execute for DDL/DML. \
3898 Call get_readme for a concise tool index, parameter rules, and usage examples."
3899 };
3900 let instructions = format!("{header}{sql_dialect}");
3901 let mut server_info = Implementation::default();
3902 server_info.name = "HyperDB".into();
3903 server_info.title = Some("HyperDB — Hyper SQL Analytics".into());
3904 server_info.version = env!("CARGO_PKG_VERSION").into();
3905 server_info.description = Some(
3906 "MCP server for Tableau Hyper: instant SQL analytics over \
3907 CSV, JSON, Parquet, Arrow IPC, and Apache Iceberg with schema inference, \
3908 partial schema overrides, full-file numeric widening, and \
3909 dry-run file inspection. SQL dialect is PostgreSQL-compatible with \
3910 extensions (Salesforce Data Cloud SQL). Full SQL reference: \
3911 https://developer.salesforce.com/docs/data/data-cloud-query-guide/references/dc-sql-reference/data-cloud-sql-context.html"
3912 .into(),
3913 );
3914
3915 let mut info = ServerInfo::default();
3916 info.instructions = Some(instructions);
3917 info.server_info = server_info;
3918 info.capabilities = ServerCapabilities::builder()
3919 .enable_tools()
3920 .enable_prompts()
3921 .enable_resources()
3922 .enable_resources_subscribe()
3927 .enable_resources_list_changed()
3928 .build();
3929 info
3930 }
3931
3932 async fn initialize(
3933 &self,
3934 request: InitializeRequestParams,
3935 context: RequestContext<RoleServer>,
3936 ) -> Result<InitializeResult, rmcp::ErrorData> {
3937 let name = &request.client_info.name;
3938 let version = &request.client_info.version;
3939 let label = if version.is_empty() {
3940 name.clone()
3941 } else {
3942 format!("{name} {version}")
3943 };
3944 if let Ok(mut guard) = self.client_name.lock() {
3945 *guard = Some(label);
3946 }
3947 context.peer.set_peer_info(request);
3948 Ok(self.get_info())
3949 }
3950
3951 async fn subscribe(
3960 &self,
3961 request: SubscribeRequestParams,
3962 context: RequestContext<RoleServer>,
3963 ) -> Result<(), rmcp::ErrorData> {
3964 self.subscriptions.subscribe(&request.uri, context.peer);
3965 Ok(())
3966 }
3967
3968 async fn unsubscribe(
3973 &self,
3974 request: UnsubscribeRequestParams,
3975 context: RequestContext<RoleServer>,
3976 ) -> Result<(), rmcp::ErrorData> {
3977 self.subscriptions.unsubscribe(&request.uri, &context.peer);
3978 Ok(())
3979 }
3980
3981 async fn list_resources(
3987 &self,
3988 _request: Option<PaginatedRequestParams>,
3989 _context: RequestContext<RoleServer>,
3990 ) -> Result<ListResourcesResult, rmcp::ErrorData> {
3991 let mut resources = vec![
3992 RawResource {
3993 uri: "hyper://workspace".into(),
3994 name: "Workspace Info".into(),
3995 title: Some("Hyper Workspace".into()),
3996 description: Some("Workspace mode, table count, total rows, disk usage".into()),
3997 mime_type: Some("application/json".into()),
3998 size: None,
3999 icons: None,
4000 meta: None,
4001 }
4002 .no_annotation(),
4003 RawResource {
4004 uri: "hyper://tables".into(),
4005 name: "All Tables".into(),
4006 title: Some("All Tables".into()),
4007 description: Some("List of all tables with column schemas and row counts".into()),
4008 mime_type: Some("application/json".into()),
4009 size: None,
4010 icons: None,
4011 meta: None,
4012 }
4013 .no_annotation(),
4014 RawResource {
4015 uri: "hyper://readme".into(),
4016 name: "Workspace Readme".into(),
4017 title: Some("HyperDB workspace readme".into()),
4018 description: Some(
4019 "Markdown overview of the workspace: tables, row counts, related \
4020 resources, and tool hints for LLMs orienting themselves."
4021 .into(),
4022 ),
4023 mime_type: Some("text/markdown".into()),
4024 size: None,
4025 icons: None,
4026 meta: None,
4027 }
4028 .no_annotation(),
4029 ];
4030
4031 if let Ok(tables) = self.with_engine(super::engine::Engine::describe_tables) {
4032 for table in tables {
4036 if let Some(name) = table.get("name").and_then(|v| v.as_str()) {
4037 let row_count = table
4038 .get("row_count")
4039 .and_then(serde_json::Value::as_i64)
4040 .unwrap_or(0);
4041 resources.push(
4042 RawResource {
4043 uri: format!("hyper://tables/{name}/schema"),
4044 name: format!("Schema of {name}"),
4045 title: Some(format!("{name} schema")),
4046 description: Some(format!(
4047 "Column schema and row count ({row_count} rows) for table '{name}'"
4048 )),
4049 mime_type: Some("application/json".into()),
4050 size: None,
4051 icons: None,
4052 meta: None,
4053 }
4054 .no_annotation(),
4055 );
4056 resources.push(
4057 RawResource {
4058 uri: format!("hyper://tables/{name}/sample"),
4059 name: format!("Sample of {name}"),
4060 title: Some(format!("{name} sample (JSON)")),
4061 description: Some(format!(
4062 "First {TABLE_SAMPLE_ROWS} rows of '{name}' as JSON, with schema"
4063 )),
4064 mime_type: Some("application/json".into()),
4065 size: None,
4066 icons: None,
4067 meta: None,
4068 }
4069 .no_annotation(),
4070 );
4071 resources.push(
4072 RawResource {
4073 uri: format!("hyper://tables/{name}/csv-sample"),
4074 name: format!("CSV sample of {name}"),
4075 title: Some(format!("{name} sample (CSV)")),
4076 description: Some(format!(
4077 "First {TABLE_CSV_SAMPLE_ROWS} rows of '{name}' as CSV"
4078 )),
4079 mime_type: Some("text/csv".into()),
4080 size: None,
4081 icons: None,
4082 meta: None,
4083 }
4084 .no_annotation(),
4085 );
4086 }
4087 }
4088 }
4089
4090 let store = Arc::clone(&self.saved_queries);
4091 if let Ok(saved) = self.with_saved_query_store(|engine| store.list(engine)) {
4092 for q in saved {
4093 let desc = q
4094 .description
4095 .clone()
4096 .unwrap_or_else(|| format!("Saved read-only SQL query '{}'", q.name));
4097 resources.push(
4098 RawResource {
4099 uri: format!("hyper://queries/{}/definition", q.name),
4100 name: format!("Query: {}", q.name),
4101 title: Some(format!("{} (definition)", q.name)),
4102 description: Some(format!("SQL + metadata for saved query '{}'", q.name)),
4103 mime_type: Some("application/json".into()),
4104 size: None,
4105 icons: None,
4106 meta: None,
4107 }
4108 .no_annotation(),
4109 );
4110 resources.push(
4111 RawResource {
4112 uri: format!("hyper://queries/{}/result", q.name),
4113 name: format!("Result: {}", q.name),
4114 title: Some(format!("{} (result)", q.name)),
4115 description: Some(format!("{desc} — re-runs on every read")),
4116 mime_type: Some("application/json".into()),
4117 size: None,
4118 icons: None,
4119 meta: None,
4120 }
4121 .no_annotation(),
4122 );
4123 }
4124 }
4125
4126 Ok(ListResourcesResult {
4127 resources,
4128 next_cursor: None,
4129 meta: None,
4130 })
4131 }
4132
4133 async fn list_resource_templates(
4136 &self,
4137 _request: Option<PaginatedRequestParams>,
4138 _context: RequestContext<RoleServer>,
4139 ) -> Result<ListResourceTemplatesResult, rmcp::ErrorData> {
4140 let templates = vec![
4141 RawResourceTemplate {
4142 uri_template: "hyper://tables/{name}/schema".into(),
4143 name: "Table Schema".into(),
4144 title: Some("Table Schema".into()),
4145 description: Some(
4146 "Column schema, types, nullability, and row count for a named table".into(),
4147 ),
4148 mime_type: Some("application/json".into()),
4149 icons: None,
4150 }
4151 .no_annotation(),
4152 RawResourceTemplate {
4153 uri_template: "hyper://tables/{name}/sample".into(),
4154 name: "Table Sample (JSON)".into(),
4155 title: Some("Table Sample".into()),
4156 description: Some(
4157 "First few rows of a named table as JSON, with schema. For a \
4158 configurable row count use the `sample` tool instead."
4159 .into(),
4160 ),
4161 mime_type: Some("application/json".into()),
4162 icons: None,
4163 }
4164 .no_annotation(),
4165 RawResourceTemplate {
4166 uri_template: "hyper://tables/{name}/csv-sample".into(),
4167 name: "Table Sample (CSV)".into(),
4168 title: Some("Table Sample (CSV)".into()),
4169 description: Some(
4170 "First few rows of a named table as CSV, header-first, for \
4171 spreadsheet and Pandas consumers."
4172 .into(),
4173 ),
4174 mime_type: Some("text/csv".into()),
4175 icons: None,
4176 }
4177 .no_annotation(),
4178 RawResourceTemplate {
4179 uri_template: "hyper://queries/{name}/definition".into(),
4180 name: "Saved Query Definition".into(),
4181 title: Some("Saved Query Definition".into()),
4182 description: Some(
4183 "Stored SQL plus metadata (description, created_at) for a saved \
4184 query registered via the `save_query` tool."
4185 .into(),
4186 ),
4187 mime_type: Some("application/json".into()),
4188 icons: None,
4189 }
4190 .no_annotation(),
4191 RawResourceTemplate {
4192 uri_template: "hyper://queries/{name}/result".into(),
4193 name: "Saved Query Result".into(),
4194 title: Some("Saved Query Result".into()),
4195 description: Some(
4196 "Live result of a saved query. The stored SQL re-runs on every \
4197 resource read — no caching, always fresh."
4198 .into(),
4199 ),
4200 mime_type: Some("application/json".into()),
4201 icons: None,
4202 }
4203 .no_annotation(),
4204 ];
4205 Ok(ListResourceTemplatesResult {
4206 resource_templates: templates,
4207 next_cursor: None,
4208 meta: None,
4209 })
4210 }
4211
4212 async fn read_resource(
4217 &self,
4218 request: ReadResourceRequestParams,
4219 _context: RequestContext<RoleServer>,
4220 ) -> Result<ReadResourceResult, rmcp::ErrorData> {
4221 let uri = &request.uri;
4222 let (mime_type, text) = match self.resource_body_for_uri(uri) {
4223 Ok(Some(body)) => (body.mime_type().to_string(), body.to_text()),
4224 Ok(None) => {
4225 return Err(rmcp::ErrorData::invalid_params(
4226 format!("Unknown resource URI: {uri}"),
4227 None,
4228 ));
4229 }
4230 Err(e) => {
4231 let err_val = serde_json::to_value(&e).unwrap_or(Value::String(e.to_string()));
4234 let text =
4235 serde_json::to_string_pretty(&json!({ "error": err_val })).unwrap_or_default();
4236 ("application/json".into(), text)
4237 }
4238 };
4239
4240 Ok(ReadResourceResult::new(vec![
4241 ResourceContents::TextResourceContents {
4242 uri: uri.clone(),
4243 mime_type: Some(mime_type),
4244 text,
4245 meta: None,
4246 },
4247 ]))
4248 }
4249}
4250
4251fn validate_execute_batch(stmts: &[String]) -> Result<(), McpError> {
4275 use crate::engine::strip_leading_sql_comments;
4276
4277 if stmts.is_empty() {
4278 return Err(McpError::new(
4279 ErrorCode::InvalidArgument,
4280 "`sql` must be a non-empty array of SQL statements.",
4281 )
4282 .with_suggestion("Pass at least one statement: `sql: [\"INSERT INTO t VALUES (1)\"]`."));
4283 }
4284
4285 let mut has_schema_change = false;
4286 let mut has_data_mutation = false;
4287
4288 for (idx, stmt) in stmts.iter().enumerate() {
4289 if strip_leading_sql_comments(stmt).trim().is_empty() {
4290 return Err(McpError::new(
4291 ErrorCode::InvalidArgument,
4292 format!("`sql[{idx}]` is empty or contains only whitespace/comments."),
4293 )
4294 .with_suggestion("Remove the empty element or replace it with a real statement."));
4295 }
4296 match classify_statement(stmt) {
4305 StatementKind::ReadOnly => {
4306 return Err(McpError::new(
4307 ErrorCode::SqlError,
4308 format!(
4309 "`sql[{idx}]` is a read-only statement; the `execute` tool is for DDL/DML."
4310 ),
4311 )
4312 .with_suggestion(
4313 "Use the `query` tool for SELECT/WITH/EXPLAIN/SHOW/VALUES. To read-then-write atomically, fold the read into the write (e.g. `UPDATE … FROM (SELECT …)` or `INSERT … SELECT …`).",
4314 ));
4315 }
4316 StatementKind::TransactionControl => {
4317 return Err(McpError::new(
4325 ErrorCode::InvalidArgument,
4326 format!(
4327 "`sql[{idx}]` is a transaction-control statement (BEGIN / COMMIT / ROLLBACK / SAVEPOINT); these are not allowed in `execute` batches."
4328 ),
4329 )
4330 .with_suggestion(
4331 "The `execute` tool manages the transaction for you — multi-element arrays already run inside BEGIN/COMMIT. Just pass the DML statements you want to run atomically.",
4332 ));
4333 }
4334 StatementKind::Ddl => has_schema_change = true,
4335 StatementKind::Dml => has_data_mutation = true,
4336 StatementKind::Other => {}
4337 }
4338 }
4339
4340 if has_schema_change && has_data_mutation {
4341 return Err(McpError::new(
4342 ErrorCode::InvalidArgument,
4343 "Cannot mix DDL (CREATE/DROP/ALTER/TRUNCATE/RENAME) with DML (INSERT/UPDATE/DELETE/COPY/MERGE) in one `execute` batch.",
4344 )
4345 .with_suggestion(
4346 "Hyper aborts such transactions with SQLSTATE 0A000. Issue DDL in a separate `execute` call from DML — DDL singletons run as their own auto-commit unit.",
4347 ));
4348 }
4349
4350 if has_schema_change && stmts.len() > 1 {
4351 return Err(McpError::new(
4352 ErrorCode::InvalidArgument,
4353 "Multi-statement DDL batches are not supported.",
4354 )
4355 .with_suggestion(
4356 "Hyper auto-commits CREATE/DROP/ALTER even inside a transaction, so wrapping multiple DDL statements in one `execute` call cannot guarantee atomicity. Issue each DDL as its own single-element `execute` call.",
4357 ));
4358 }
4359
4360 Ok(())
4361}
4362
4363fn value_to_csv_cell(v: &Value) -> String {
4369 match v {
4370 Value::Null => String::new(),
4371 Value::Bool(b) => b.to_string(),
4372 Value::Number(n) => n.to_string(),
4373 Value::String(s) => s.clone(),
4374 _ => v.to_string(),
4375 }
4376}
4377
4378fn detect_format(data: &str) -> String {
4381 let trimmed = data.trim_start();
4382 if trimmed.starts_with('[') || trimmed.starts_with('{') {
4383 "json".into()
4384 } else {
4385 "csv".into()
4386 }
4387}
4388
4389fn rand_suffix() -> String {
4393 use std::time::{SystemTime, UNIX_EPOCH};
4394 let t = SystemTime::now()
4395 .duration_since(UNIX_EPOCH)
4396 .unwrap_or_default();
4397 format!("{}", t.as_nanos() % 1_000_000_000)
4398}
4399
4400fn qualified_name(engine: &Engine, db: Option<&str>, table: &str) -> String {
4411 let alias = db.map_or_else(|| engine.primary_db_name(), str::to_string);
4412 let escaped_alias = alias.replace('"', "\"\"");
4413 let escaped_table = table.replace('"', "\"\"");
4414 format!("\"{escaped_alias}\".\"public\".\"{escaped_table}\"")
4415}
4416
4417fn target_exists(engine: &Engine, db: Option<&str>, table: &str) -> Result<bool, McpError> {
4422 let sql = format!(
4423 "SELECT 1 FROM {} LIMIT 0",
4424 qualified_name(engine, db, table)
4425 );
4426 match engine.execute_query_to_json(&sql) {
4427 Ok(_) => Ok(true),
4428 Err(e) => {
4429 let m = e.message.to_lowercase();
4430 let missing = m.contains("does not exist")
4431 || m.contains("undefined table")
4432 || e.message.contains("42P01");
4433 if missing {
4434 Ok(false)
4435 } else {
4436 Err(e)
4437 }
4438 }
4439 }
4440}
4441
4442fn count_rows(engine: &Engine, db: Option<&str>, table: &str) -> i64 {
4447 let sql = format!(
4448 "SELECT COUNT(*) AS cnt FROM {}",
4449 qualified_name(engine, db, table)
4450 );
4451 engine
4452 .execute_query_to_json(&sql)
4453 .ok()
4454 .and_then(|rows| {
4455 rows.first()
4456 .and_then(|r| r.get("cnt").and_then(serde_json::Value::as_i64))
4457 })
4458 .unwrap_or(0)
4459}
4460
4461fn probe_table_count(engine: &Engine, alias: &str) -> Value {
4465 let escaped_alias = alias.replace('"', "\"\"");
4466 let sql = format!(
4467 "SELECT COUNT(*) AS cnt FROM \"{escaped_alias}\".pg_catalog.pg_tables WHERE schemaname = 'public'"
4468 );
4469 match engine.execute_query_to_json(&sql) {
4470 Ok(rows) => rows
4471 .first()
4472 .and_then(|r| r.get("cnt").and_then(serde_json::Value::as_i64))
4473 .map_or(Value::Null, |n| json!(n)),
4474 Err(_) => Value::Null,
4475 }
4476}
4477
4478fn prepare_temp_attachments(
4482 specs: &[AttachSpec],
4483 read_only: bool,
4484) -> Result<Vec<AttachRequest>, McpError> {
4485 let mut out = Vec::with_capacity(specs.len());
4486 for spec in specs {
4487 let writable = spec.writable.unwrap_or(false);
4488 if writable && read_only {
4489 return Err(McpError::new(
4490 ErrorCode::ReadOnlyViolation,
4491 format!(
4492 "temp_attach for alias '{}' requested writable:true but the server is --read-only",
4493 spec.alias
4494 ),
4495 ));
4496 }
4497 let on_missing = attach::OnMissing::parse(spec.on_missing.as_deref())?;
4498 if on_missing == attach::OnMissing::Create && !writable {
4499 return Err(McpError::new(
4500 ErrorCode::InvalidArgument,
4501 format!(
4502 "temp_attach alias '{}' has on_missing='create' but writable is not true — \
4503 an empty .hyper file that cannot be written to cannot be populated.",
4504 spec.alias
4505 ),
4506 ));
4507 }
4508 let source = match spec.kind.as_str() {
4509 "local_file" => {
4510 let Some(raw) = spec.path.as_deref() else {
4511 return Err(McpError::new(
4512 ErrorCode::InvalidArgument,
4513 format!("temp_attach alias '{}' requires a 'path'", spec.alias),
4514 ));
4515 };
4516 let resolved = match on_missing {
4517 attach::OnMissing::Error => attach::validate_local_path(raw)?,
4518 attach::OnMissing::Create => attach::validate_local_path_for_create(raw)?,
4519 };
4520 AttachSource::LocalFile { path: resolved }
4521 }
4522 other => {
4523 return Err(McpError::new(
4524 ErrorCode::InvalidArgument,
4525 format!(
4526 "Unsupported temp_attach kind '{other}' for alias '{}'. Only 'local_file' is supported today.",
4527 spec.alias
4528 ),
4529 ));
4530 }
4531 };
4532 attach::validate_alias(&spec.alias)?;
4533 out.push(AttachRequest {
4534 alias: spec.alias.clone(),
4535 source,
4536 writable,
4537 on_missing,
4538 });
4539 }
4540 Ok(out)
4541}
4542
4543fn perform_copy(
4548 engine: &Engine,
4549 mode: &str,
4550 target_db: Option<&str>,
4551 target_table: &str,
4552 sql_body: &str,
4553) -> Result<Value, McpError> {
4554 let qualified = qualified_name(engine, target_db, target_table);
4555 let exists = target_exists(engine, target_db, target_table)?;
4556 let timer = crate::stats::StatsTimer::start();
4557
4558 match mode {
4559 "create" => {
4560 if exists {
4561 return Err(McpError::new(
4562 ErrorCode::InvalidArgument,
4563 format!(
4564 "Target '{target_table}' already exists. Use mode='append' to add rows or mode='replace' to drop and recreate."
4565 ),
4566 ));
4567 }
4568 engine.execute_command(&format!("CREATE TABLE {qualified} AS {sql_body}"))?;
4569 }
4570 "append" => {
4571 if !exists {
4572 return Err(McpError::new(
4573 ErrorCode::InvalidArgument,
4574 format!(
4575 "Target '{target_table}' does not exist. Use mode='create' to create it from the query or mode='replace' to drop and recreate."
4576 ),
4577 ));
4578 }
4579 engine.execute_command(&format!("INSERT INTO {qualified} {sql_body}"))?;
4580 }
4581 "replace" => {
4582 engine.execute_command(&format!("DROP TABLE IF EXISTS {qualified}"))?;
4591 engine.execute_command(&format!("CREATE TABLE {qualified} AS {sql_body}"))?;
4592 }
4593 other => {
4594 return Err(McpError::new(
4595 ErrorCode::InvalidArgument,
4596 format!("copy_query mode '{other}' is not supported"),
4597 ));
4598 }
4599 }
4600
4601 let elapsed_ms = timer.elapsed_ms();
4602 let row_count = count_rows(engine, target_db, target_table);
4603 Ok(json!({
4604 "target_table": target_table,
4605 "target_database": target_db.unwrap_or(LOCAL_ALIAS),
4606 "mode": mode,
4607 "row_count": row_count,
4608 "stats": { "operation": "copy_query", "elapsed_ms": elapsed_ms },
4609 }))
4610}
4611
4612#[cfg(test)]
4613mod validate_execute_batch_tests {
4614 use super::*;
4615
4616 fn s(v: &[&str]) -> Vec<String> {
4617 v.iter().map(|x| (*x).to_string()).collect()
4618 }
4619
4620 #[test]
4621 fn rejects_empty_array() {
4622 let err = validate_execute_batch(&[]).unwrap_err();
4623 assert_eq!(err.code, ErrorCode::InvalidArgument);
4624 }
4625
4626 #[test]
4627 fn rejects_whitespace_only_element() {
4628 let err = validate_execute_batch(&s(&[" "])).unwrap_err();
4629 assert_eq!(err.code, ErrorCode::InvalidArgument);
4630 assert!(err.message.contains("sql[0]"));
4631 let err = validate_execute_batch(&s(&["INSERT INTO t VALUES (1)", "/* */"])).unwrap_err();
4632 assert!(err.message.contains("sql[1]"));
4633 }
4634
4635 #[test]
4636 fn rejects_read_only_element() {
4637 let err =
4638 validate_execute_batch(&s(&["INSERT INTO t VALUES (1)", "SELECT 1"])).unwrap_err();
4639 assert_eq!(err.code, ErrorCode::SqlError);
4640 assert!(err.message.contains("sql[1]"));
4641 }
4642
4643 #[test]
4644 fn rejects_transaction_control_in_batch() {
4645 for sql in [
4648 "BEGIN",
4649 "COMMIT",
4650 "ROLLBACK",
4651 "SAVEPOINT sp1",
4652 "START TRANSACTION",
4653 "END",
4654 "RELEASE SAVEPOINT sp1",
4655 ] {
4656 let err = validate_execute_batch(&s(&["INSERT INTO t VALUES (1)", sql])).unwrap_err();
4657 assert_eq!(err.code, ErrorCode::InvalidArgument, "for `{sql}`");
4658 assert!(
4659 err.message.contains("transaction-control"),
4660 "for `{sql}`: {}",
4661 err.message
4662 );
4663 }
4664 }
4665
4666 #[test]
4667 fn rejects_transaction_control_singleton() {
4668 let err = validate_execute_batch(&s(&["BEGIN"])).unwrap_err();
4673 assert_eq!(err.code, ErrorCode::InvalidArgument);
4674 }
4675
4676 #[test]
4677 fn rejects_ddl_dml_mix() {
4678 let err =
4679 validate_execute_batch(&s(&["CREATE TABLE x (i INT)", "INSERT INTO x VALUES (1)"]))
4680 .unwrap_err();
4681 assert_eq!(err.code, ErrorCode::InvalidArgument);
4682 assert!(err.message.contains("DDL"));
4683 }
4684
4685 #[test]
4686 fn rejects_multi_ddl() {
4687 let err = validate_execute_batch(&s(&["CREATE TABLE a (i INT)", "CREATE TABLE b (j INT)"]))
4688 .unwrap_err();
4689 assert_eq!(err.code, ErrorCode::InvalidArgument);
4690 assert!(err.message.contains("Multi-statement DDL"));
4691 }
4692
4693 #[test]
4694 fn allows_single_ddl() {
4695 validate_execute_batch(&s(&["CREATE TABLE a (i INT)"])).unwrap();
4696 }
4697
4698 #[test]
4699 fn allows_multi_dml() {
4700 validate_execute_batch(&s(&[
4701 "UPDATE settings SET value = 'x' WHERE key = 'k'",
4702 "INSERT INTO settings (key, value) SELECT 'k', 'x' WHERE NOT EXISTS (SELECT 1 FROM settings WHERE key = 'k')",
4703 ]))
4704 .unwrap();
4705 }
4706
4707 #[test]
4708 fn allows_other_kinds() {
4709 validate_execute_batch(&s(&["SET schema_search_path = 'mydb'"])).unwrap();
4713 }
4714
4715 #[test]
4716 fn allows_trailing_semicolon() {
4717 validate_execute_batch(&s(&["INSERT INTO t VALUES (1);"])).unwrap();
4718 }
4719}