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 {
1244 self.maybe_send_heartbeat(engine.daemon_health_port());
1245 }
1246 let result = f(engine);
1247 if let Err(e) = &result {
1248 tracing::debug!(code = ?e.code, message = %e.message, "tool call returned error");
1249 if e.code == ErrorCode::ConnectionLost {
1250 tracing::warn!(
1251 "connection to hyperd lost or desynchronized ({}); \
1256 dropping engine so next call reconnects",
1257 e.message
1258 );
1259 *guard = None;
1260 if let Ok(mut ready) = self.catalog_ready.lock() {
1263 *ready = false;
1264 }
1265 if !self.no_daemon {
1269 crate::daemon::health::report_hyperd_error_to_daemon();
1270 }
1271 }
1272 }
1273 result
1274 }
1275
1276 fn maybe_send_heartbeat(&self, daemon_health_port: Option<u16>) {
1284 const HEARTBEAT_INTERVAL: std::time::Duration = std::time::Duration::from_secs(60);
1285 let should_send = self
1286 .last_heartbeat
1287 .lock()
1288 .is_ok_and(|guard| guard.elapsed() >= HEARTBEAT_INTERVAL);
1289 if should_send {
1290 if let Some(port) = daemon_health_port {
1291 let _ = crate::daemon::health::send_command(port, "HEARTBEAT");
1292 if let Ok(mut guard) = self.last_heartbeat.lock() {
1293 *guard = std::time::Instant::now();
1294 }
1295 }
1296 }
1297 }
1298
1299 fn status_degraded(&self) -> Value {
1312 let (hyperd_running, engine_block) = if let Some(info) =
1317 crate::daemon::discovery::discover()
1318 {
1319 (
1320 true,
1321 json!({
1322 "mode": "daemon",
1323 "hyperd_endpoint": info.hyperd_endpoint,
1324 "daemon_health_port": info.health_port,
1325 }),
1326 )
1327 } else if self.no_daemon {
1328 (
1330 false,
1331 json!({ "mode": "local", "hyperd_endpoint": null, "daemon_health_port": null }),
1332 )
1333 } else {
1334 (
1335 false,
1336 json!({ "mode": "daemon", "hyperd_endpoint": null, "daemon_health_port": null }),
1337 )
1338 };
1339
1340 let persistent_path = self
1341 .workspace_path
1342 .as_ref()
1343 .map_or(Value::Null, |p| Value::String(p.clone()));
1344
1345 let attachments: Vec<Value> = self
1346 .attachments
1347 .list()
1348 .iter()
1349 .map(super::attach::AttachedDb::to_json)
1350 .collect();
1351
1352 json!({
1353 "engine_busy": true,
1354 "hyperd_running": hyperd_running,
1355 "persistent_path": persistent_path,
1356 "has_persistent": self.workspace_path.is_some(),
1357 "engine": engine_block,
1358 "hyper_rust_api_version": crate::version::mcp_version_string(),
1359 "watchers": self.watchers.to_json(),
1360 "read_only": self.read_only,
1361 "attachments": attachments,
1362 })
1363 }
1364
1365 fn with_saved_query_store<F, R>(&self, f: F) -> Result<R, McpError>
1375 where
1376 F: FnOnce(Option<&Engine>) -> Result<R, McpError>,
1377 {
1378 if self.workspace_path.is_some() {
1379 self.with_engine(|engine| f(Some(engine)))
1380 } else {
1381 f(None)
1382 }
1383 }
1384
1385 #[expect(
1386 clippy::unnecessary_wraps,
1387 reason = "signature retained for API symmetry / future fallibility; returning Result/Option keeps callers from breaking when the function later grows failure cases"
1388 )]
1389 fn ok_content(val: Value) -> Result<CallToolResult, rmcp::ErrorData> {
1394 let text = serde_json::to_string_pretty(&val).unwrap_or_default();
1395 let mut result = CallToolResult::structured(val);
1396 result.content = vec![Content::text(text)];
1400 Ok(result)
1401 }
1402
1403 fn fmt_sql(sql: &str) -> String {
1406 let opts = FormatOptions {
1407 indent: Indent::Spaces(2),
1408 uppercase: Some(true),
1409 lines_between_queries: 1,
1410 ..FormatOptions::default()
1411 };
1412 let formatted = sqlformat::format(sql, &SqlQueryParams::None, &opts);
1413 if formatted.trim().is_empty() {
1414 sql.to_owned()
1415 } else {
1416 formatted
1417 }
1418 }
1419
1420 #[expect(
1421 clippy::unnecessary_wraps,
1422 reason = "signature retained for API symmetry / future fallibility; returning Result/Option keeps callers from breaking when the function later grows failure cases"
1423 )]
1424 #[expect(
1425 clippy::needless_pass_by_value,
1426 reason = "call-site ergonomics: function consumes logically-owned parameters, refactoring signatures is not worth per-site churn"
1427 )]
1428 fn err_content(e: McpError) -> Result<CallToolResult, rmcp::ErrorData> {
1433 let err_val = serde_json::to_value(&e).unwrap_or(Value::String(e.to_string()));
1434 let body = json!({"error": err_val});
1435 let text = serde_json::to_string_pretty(&body).unwrap_or_default();
1436 let mut result = CallToolResult::structured_error(body);
1437 result.content = vec![Content::text(text)];
1438 Ok(result)
1439 }
1440}
1441
1442#[tool_router]
1443impl HyperMcpServer {
1444 #[tool(
1446 description = "Ingest inline data (JSON or CSV) and run a SQL query in one call. Creates a temp table, queries, discards."
1447 )]
1448 fn query_data(
1449 &self,
1450 Parameters(params): Parameters<QueryDataParams>,
1451 ) -> Result<CallToolResult, rmcp::ErrorData> {
1452 let result = self.with_engine(|engine| {
1453 let tname = params.table_name.unwrap_or_else(|| "data".into());
1454 let temp_table = format!("_tmp_{}_{}", tname, rand_suffix());
1455 let fmt = params.format.unwrap_or_else(|| detect_format(¶ms.data));
1456 let schema_override = crate::schema::normalize_schema_param(params.schema.as_ref())?;
1457 let opts = IngestOptions {
1458 table: temp_table.clone(),
1459 mode: "replace".into(),
1460 schema_override,
1461 merge_key: None,
1462 target_db: None,
1463 };
1464
1465 let ingest_result = match fmt.as_str() {
1466 "csv" => ingest_csv(engine, ¶ms.data, &opts),
1467 _ => ingest_json(engine, ¶ms.data, &opts),
1468 }?;
1469
1470 let query_sql = params.sql.replace(&tname, &temp_table);
1471 let rows = engine.execute_query_to_json(&query_sql)?;
1472 let _ = engine.execute_command(&format!("DROP TABLE IF EXISTS \"{temp_table}\""));
1473
1474 Ok(json!({
1475 "sql": Self::fmt_sql(¶ms.sql),
1476 "result": rows,
1477 "stats": ingest_result.stats.to_json(),
1478 }))
1479 });
1480
1481 match result {
1482 Ok(val) => Self::ok_content(val),
1483 Err(e) => Self::err_content(e),
1484 }
1485 }
1486
1487 #[tool(
1489 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."
1490 )]
1491 fn query_file(
1492 &self,
1493 Parameters(params): Parameters<QueryFileParams>,
1494 ) -> Result<CallToolResult, rmcp::ErrorData> {
1495 let result = self.with_engine(|engine| {
1496 crate::attach::validate_input_path(¶ms.path, "data file")?;
1497 let stem = std::path::Path::new(¶ms.path)
1498 .file_stem()
1499 .and_then(|s| s.to_str())
1500 .unwrap_or("file")
1501 .to_string();
1502 let tname = params.table_name.unwrap_or_else(|| stem.clone());
1503 let temp_table = format!("_tmp_{}_{}", tname, rand_suffix());
1504 let schema_override = crate::schema::normalize_schema_param(params.schema.as_ref())?;
1505 let opts = IngestOptions {
1506 table: temp_table.clone(),
1507 mode: "replace".into(),
1508 schema_override,
1509 merge_key: None,
1510 target_db: None,
1511 };
1512
1513 let ingest_result = if let Some(ref json_path) = params.json_extract_path {
1514 let raw = std::fs::read_to_string(¶ms.path).map_err(|e| {
1515 McpError::new(
1516 ErrorCode::FileNotFound,
1517 format!("Cannot read file '{}': {e}", params.path),
1518 )
1519 })?;
1520 let extracted = crate::ingest::extract_json_path(&raw, json_path)?;
1521 let array_text = crate::ingest::normalize_json_or_jsonl(&extracted)?;
1522 let mut result = ingest_json(engine, &array_text, &opts)?;
1523 result.stats.operation = "query_file".into();
1524 result.stats.bytes_read = std::fs::metadata(¶ms.path).map_or(0, |m| m.len());
1525 result.stats.file_format = Some("json".into());
1526 result
1527 } else {
1528 match detect_file_format(std::path::Path::new(¶ms.path)) {
1529 InferredFileFormat::Parquet => ingest_parquet_file(engine, ¶ms.path, &opts),
1530 InferredFileFormat::ArrowIpc => {
1531 ingest_arrow_ipc_file(engine, ¶ms.path, &opts)
1532 }
1533 InferredFileFormat::Json => ingest_json_file(engine, ¶ms.path, &opts),
1534 InferredFileFormat::Csv => ingest_csv_file(engine, ¶ms.path, &opts),
1535 }?
1536 };
1537
1538 let query_sql = params.sql.replace(&tname, &temp_table);
1539 let rows = engine.execute_query_to_json(&query_sql)?;
1540 let _ = engine.execute_command(&format!("DROP TABLE IF EXISTS \"{temp_table}\""));
1541
1542 Ok(json!({
1543 "sql": Self::fmt_sql(¶ms.sql),
1544 "result": rows,
1545 "stats": ingest_result.stats.to_json(),
1546 }))
1547 });
1548
1549 match result {
1550 Ok(val) => Self::ok_content(val),
1551 Err(e) => Self::err_content(e),
1552 }
1553 }
1554
1555 #[tool(
1557 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))."
1558 )]
1559 fn load_data(
1560 &self,
1561 Parameters(params): Parameters<LoadDataParams>,
1562 ) -> Result<CallToolResult, rmcp::ErrorData> {
1563 if let Err(e) = self.check_writable("load_data") {
1564 return Self::err_content(e);
1565 }
1566 let table_name = params.table.clone();
1567 let mode = params.mode.clone().unwrap_or_else(|| "replace".into());
1572 let result = self.with_engine(|engine| {
1573 let target_db =
1574 self.resolve_db(engine, params.database.as_deref(), params.persist, true)?;
1575 let fmt = params.format.unwrap_or_else(|| detect_format(¶ms.data));
1576 let schema_override = crate::schema::normalize_schema_param(params.schema.as_ref())?;
1577 let opts = IngestOptions {
1578 table: params.table.clone(),
1579 mode: mode.clone(),
1580 schema_override,
1581 merge_key: None,
1582 target_db: target_db.clone(),
1583 };
1584
1585 let ingest_result = match fmt.as_str() {
1586 "csv" => ingest_csv(engine, ¶ms.data, &opts),
1587 _ => ingest_json(engine, ¶ms.data, &opts),
1588 }?;
1589
1590 let schema_json: Vec<Value> = ingest_result
1591 .schema
1592 .iter()
1593 .map(|c| {
1594 json!({
1595 "name": c.name,
1596 "type": c.hyper_type,
1597 "nullable": c.nullable,
1598 })
1599 })
1600 .collect();
1601
1602 {
1608 let load_params = serde_json::to_string(&json!({
1609 "mode": mode,
1610 "format": fmt,
1611 "database": target_db.as_deref().unwrap_or("local"),
1612 }))
1613 .ok();
1614 self.after_ingest_catalog_update(
1615 engine,
1616 ¶ms.table,
1617 "load_data",
1618 load_params.as_deref(),
1619 i64::try_from(ingest_result.rows).ok(),
1620 target_db.as_deref(),
1621 );
1622 }
1623
1624 Ok(json!({
1625 "rows": ingest_result.rows,
1626 "schema": schema_json,
1627 "stats": ingest_result.stats.to_json(),
1628 }))
1629 });
1630
1631 match result {
1632 Ok(val) => {
1633 self.notify_table_changed(&table_name);
1634 if mode == "replace" {
1635 self.notify_resource_list_changed();
1639 }
1640 Self::ok_content(val)
1641 }
1642 Err(e) => Self::err_content(e),
1643 }
1644 }
1645
1646 #[tool(
1648 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."
1649 )]
1650 fn load_file(
1651 &self,
1652 Parameters(params): Parameters<LoadFileParams>,
1653 ) -> Result<CallToolResult, rmcp::ErrorData> {
1654 if let Err(e) = self.check_writable("load_file") {
1655 return Self::err_content(e);
1656 }
1657 let table_name = params.table.clone();
1658 let mode = params.mode.clone().unwrap_or_else(|| "replace".into());
1659 let merge_key_vec = match validate_merge_args(&mode, params.merge_key) {
1663 Ok(v) => v,
1664 Err(e) => return Self::err_content(e),
1665 };
1666 let result = self.with_engine(|engine| {
1671 let target_db =
1672 self.resolve_db(engine, params.database.as_deref(), params.persist, true)?;
1673 crate::attach::validate_input_path(¶ms.path, "data file")?;
1674 let schema_override = crate::schema::normalize_schema_param(params.schema.as_ref())?;
1675 let opts = IngestOptions {
1676 table: params.table.clone(),
1677 mode: mode.clone(),
1678 schema_override,
1679 merge_key: merge_key_vec.clone(),
1680 target_db: target_db.clone(),
1681 };
1682
1683 let ingest_result = if let Some(ref json_path) = params.json_extract_path {
1684 let raw = std::fs::read_to_string(¶ms.path).map_err(|e| {
1685 McpError::new(
1686 ErrorCode::FileNotFound,
1687 format!("Cannot read file '{}': {e}", params.path),
1688 )
1689 })?;
1690 let extracted = crate::ingest::extract_json_path(&raw, json_path)?;
1691 let array_text = crate::ingest::normalize_json_or_jsonl(&extracted)?;
1692 let mut result = ingest_json(engine, &array_text, &opts)?;
1693 result.stats.operation = "load_file".into();
1694 result.stats.bytes_read = std::fs::metadata(¶ms.path).map_or(0, |m| m.len());
1695 result.stats.file_format = Some("json".into());
1696 result
1697 } else {
1698 match detect_file_format(std::path::Path::new(¶ms.path)) {
1699 InferredFileFormat::Parquet => ingest_parquet_file(engine, ¶ms.path, &opts),
1700 InferredFileFormat::ArrowIpc => {
1701 ingest_arrow_ipc_file(engine, ¶ms.path, &opts)
1702 }
1703 InferredFileFormat::Json => ingest_json_file(engine, ¶ms.path, &opts),
1704 InferredFileFormat::Csv => ingest_csv_file(engine, ¶ms.path, &opts),
1705 }?
1706 };
1707
1708 let schema_changed = ingest_result.stats.schema_changed;
1712
1713 let schema_json: Vec<Value> = ingest_result
1714 .schema
1715 .iter()
1716 .map(|c| {
1717 json!({
1718 "name": c.name,
1719 "type": c.hyper_type,
1720 "nullable": c.nullable,
1721 })
1722 })
1723 .collect();
1724
1725 {
1727 let load_params = serde_json::to_string(&json!({
1728 "source_path": params.path,
1729 "mode": mode,
1730 "schema": params.schema,
1731 "json_extract_path": params.json_extract_path,
1732 "merge_key": merge_key_vec,
1733 "database": target_db.as_deref().unwrap_or("local"),
1734 }))
1735 .ok();
1736 self.after_ingest_catalog_update(
1737 engine,
1738 ¶ms.table,
1739 "load_file",
1740 load_params.as_deref(),
1741 i64::try_from(ingest_result.rows).ok(),
1742 target_db.as_deref(),
1743 );
1744 }
1745
1746 Ok((
1747 json!({
1748 "rows": ingest_result.rows,
1749 "schema": schema_json,
1750 "stats": ingest_result.stats.to_json(),
1751 }),
1752 schema_changed,
1753 ))
1754 });
1755
1756 match result {
1757 Ok((val, schema_changed)) => {
1758 self.notify_table_changed(&table_name);
1759 if mode == "replace" || schema_changed {
1767 self.notify_resource_list_changed();
1768 }
1769 Self::ok_content(val)
1770 }
1771 Err(e) => Self::err_content(e),
1772 }
1773 }
1774
1775 #[tool(
1779 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.**"
1780 )]
1781 fn load_files(
1782 &self,
1783 Parameters(params): Parameters<LoadFilesParams>,
1784 ) -> Result<CallToolResult, rmcp::ErrorData> {
1785 use hyperdb_api::pool::{create_pool, PoolConfig};
1786 use hyperdb_api::CreateMode;
1787
1788 if let Err(e) = self.check_writable("load_files") {
1789 return Self::err_content(e);
1790 }
1791 if params.files.is_empty() {
1792 return Self::err_content(McpError::new(
1793 ErrorCode::EmptyData,
1794 "load_files: `files` must not be empty",
1795 ));
1796 }
1797
1798 for (idx, entry) in params.files.iter().enumerate() {
1805 if let Err(mut e) = crate::attach::validate_input_path(&entry.path, "data file") {
1806 e.message = format!("entry {idx} (table '{}'): {}", entry.table, e.message);
1807 return Self::err_content(e);
1808 }
1809 let mode = entry.mode.as_deref().unwrap_or("replace");
1810 if mode == "merge" || entry.merge_key.is_some() {
1811 return Self::err_content(McpError::new(
1812 ErrorCode::InvalidArgument,
1813 format!(
1814 "load_files does not support mode=merge yet (entry {idx}, table \
1815 '{}'). Call load_file once per file when you need merge semantics.",
1816 entry.table
1817 ),
1818 ));
1819 }
1820 }
1821
1822 let (endpoint, workspace, target_db) = match self.with_engine(|engine| {
1828 let target_db =
1829 self.resolve_db(engine, params.database.as_deref(), params.persist, true)?;
1830 let endpoint = engine.hyperd_endpoint()?;
1831 let workspace = match target_db.as_deref() {
1832 None => engine.ephemeral_path().to_string_lossy().to_string(),
1833 Some(alias) if alias.eq_ignore_ascii_case(Engine::PERSISTENT_ALIAS) => engine
1834 .persistent_path()
1835 .ok_or_else(|| {
1836 McpError::new(
1837 ErrorCode::InvalidArgument,
1838 "target 'persistent' but the server is in --ephemeral-only mode",
1839 )
1840 })?
1841 .to_string_lossy()
1842 .to_string(),
1843 Some(alias) => {
1844 let entry = self.attachments.get(alias).ok_or_else(|| {
1845 McpError::new(
1846 ErrorCode::InvalidArgument,
1847 format!("database '{alias}' is not attached"),
1848 )
1849 })?;
1850 let crate::attach::AttachSource::LocalFile { path } = &entry.source;
1851 path.to_string_lossy().to_string()
1852 }
1853 };
1854 Ok((endpoint, workspace, target_db))
1855 }) {
1856 Ok(v) => v,
1857 Err(e) => return Self::err_content(e),
1858 };
1859
1860 let file_count = params.files.len();
1863 let concurrency = params
1864 .concurrency
1865 .map_or(8, |n| n as usize)
1866 .min(file_count)
1867 .clamp(1, 16);
1868
1869 let pool = match create_pool(
1870 PoolConfig::new(endpoint, workspace)
1871 .create_mode(CreateMode::DoNotCreate)
1872 .max_size(concurrency),
1873 ) {
1874 Ok(p) => Arc::new(p),
1875 Err(e) => {
1876 return Self::err_content(McpError::new(
1877 ErrorCode::InternalError,
1878 format!("Failed to build connection pool for load_files: {e}"),
1879 ))
1880 }
1881 };
1882
1883 let Ok(rt) = tokio::runtime::Handle::try_current() else {
1886 return Self::err_content(McpError::new(
1887 ErrorCode::InternalError,
1888 "load_files must run inside a tokio runtime",
1889 ));
1890 };
1891
1892 #[derive(Default)]
1895 struct EntryOutcome {
1896 table: String,
1897 ok: Option<(u64, Vec<Value>, Value)>,
1898 err: Option<(ErrorCode, String)>,
1899 replace_mode: bool,
1900 }
1901
1902 let outcomes: Vec<EntryOutcome> = tokio::task::block_in_place(|| {
1903 rt.block_on(async {
1904 let mut set = tokio::task::JoinSet::new();
1905 for (idx, entry) in params.files.into_iter().enumerate() {
1906 let pool = Arc::clone(&pool);
1907 let entry_target_db = target_db.clone();
1908 set.spawn(async move {
1909 let mode = entry.mode.clone().unwrap_or_else(|| "replace".into());
1910 let replace_mode = mode == "replace";
1911 let mut out = EntryOutcome {
1912 table: entry.table.clone(),
1913 replace_mode,
1914 ..Default::default()
1915 };
1916
1917 let schema_override =
1922 match crate::schema::normalize_schema_param(entry.schema.as_ref()) {
1923 Ok(v) => v,
1924 Err(e) => {
1925 out.err = Some((e.code, e.message));
1926 return (idx, out);
1927 }
1928 };
1929 let _ = &entry_target_db;
1938 let opts = IngestOptions {
1939 table: entry.table.clone(),
1940 mode: mode.clone(),
1941 schema_override,
1942 merge_key: None,
1943 target_db: None,
1944 };
1945
1946 let mut conn = match pool.get().await {
1949 Ok(c) => c,
1950 Err(e) => {
1951 out.err = Some((
1952 ErrorCode::InternalError,
1953 format!("Failed to check out connection: {e}"),
1954 ));
1955 return (idx, out);
1956 }
1957 };
1958
1959 let ingest_res = if let Some(ref json_path) = entry.json_extract_path {
1964 let raw = match std::fs::read_to_string(&entry.path) {
1965 Ok(s) => s,
1966 Err(e) => {
1967 out.err = Some((
1968 ErrorCode::FileNotFound,
1969 format!("Cannot read file '{}': {e}", entry.path),
1970 ));
1971 return (idx, out);
1972 }
1973 };
1974 let extracted = match crate::ingest::extract_json_path(&raw, json_path)
1975 {
1976 Ok(v) => v,
1977 Err(e) => {
1978 out.err = Some((e.code, e.message));
1979 return (idx, out);
1980 }
1981 };
1982 let array_text =
1983 match crate::ingest::normalize_json_or_jsonl(&extracted) {
1984 Ok(v) => v,
1985 Err(e) => {
1986 out.err = Some((e.code, e.message));
1987 return (idx, out);
1988 }
1989 };
1990 crate::ingest::ingest_json_async(&mut conn, &array_text, &opts)
1991 .await
1992 .map(|mut r| {
1993 r.stats.operation = "load_file".into();
1994 r.stats.bytes_read =
1995 std::fs::metadata(&entry.path).map_or(0, |m| m.len());
1996 r.stats.file_format = Some("json".into());
1997 r
1998 })
1999 } else {
2000 match detect_file_format(std::path::Path::new(&entry.path)) {
2001 InferredFileFormat::Parquet => {
2002 ingest_parquet_file_async(&mut conn, &entry.path, &opts).await
2003 }
2004 InferredFileFormat::ArrowIpc => {
2005 ingest_arrow_ipc_file_async(&mut conn, &entry.path, &opts).await
2006 }
2007 InferredFileFormat::Json => {
2008 ingest_json_file_async(&mut conn, &entry.path, &opts).await
2009 }
2010 InferredFileFormat::Csv => {
2011 ingest_csv_file_async(&mut conn, &entry.path, &opts).await
2012 }
2013 }
2014 };
2015
2016 match ingest_res {
2017 Ok(r) => {
2018 let schema_json: Vec<Value> = r
2019 .schema
2020 .iter()
2021 .map(|c| {
2022 json!({
2023 "name": c.name,
2024 "type": c.hyper_type,
2025 "nullable": c.nullable,
2026 })
2027 })
2028 .collect();
2029 out.ok = Some((r.rows, schema_json, r.stats.to_json()));
2030 }
2031 Err(e) => {
2032 out.err = Some((e.code, e.message));
2033 }
2034 }
2035
2036 (idx, out)
2037 });
2038 }
2039
2040 let mut collected: Vec<Option<EntryOutcome>> =
2043 (0..file_count).map(|_| None).collect();
2044 while let Some(joined) = set.join_next().await {
2045 match joined {
2046 Ok((idx, outcome)) => collected[idx] = Some(outcome),
2047 Err(e) => {
2048 tracing::warn!("load_files task join error: {e}");
2051 }
2052 }
2053 }
2054 collected.into_iter().flatten().collect()
2055 })
2056 });
2057
2058 let mut any_replace_succeeded = false;
2062 let mut tables_to_notify: Vec<String> = Vec::new();
2063 let results_json: Vec<Value> = outcomes
2064 .iter()
2065 .map(|o| match (&o.ok, &o.err) {
2066 (Some((rows, schema, stats)), _) => {
2067 tables_to_notify.push(o.table.clone());
2068 if o.replace_mode {
2069 any_replace_succeeded = true;
2070 }
2071 json!({
2072 "table": o.table,
2073 "rows": rows,
2074 "schema": schema,
2075 "stats": stats,
2076 })
2077 }
2078 (None, Some((code, msg))) => json!({
2079 "table": o.table,
2080 "error": {
2081 "code": format!("{:?}", code),
2082 "message": msg,
2083 }
2084 }),
2085 (None, None) => json!({
2088 "table": o.table,
2089 "error": {
2090 "code": "InternalError",
2091 "message": "load_files task produced no outcome",
2092 }
2093 }),
2094 })
2095 .collect();
2096
2097 if let Err(e) = self.with_engine(|engine| {
2101 for o in &outcomes {
2102 if let Some((rows, _, _)) = &o.ok {
2103 self.after_ingest_catalog_update(
2104 engine,
2105 &o.table,
2106 "load_file",
2107 None,
2108 i64::try_from(*rows).ok(),
2109 target_db.as_deref(),
2110 );
2111 }
2112 }
2113 Ok(())
2114 }) {
2115 tracing::warn!("load_files: catalog update batch failed: {}", e.message);
2116 }
2117
2118 for t in &tables_to_notify {
2119 self.notify_table_changed(t);
2120 }
2121 if any_replace_succeeded {
2122 self.notify_resource_list_changed();
2123 }
2124
2125 let success_count = outcomes.iter().filter(|o| o.ok.is_some()).count();
2126 let failure_count = outcomes.len() - success_count;
2127
2128 Self::ok_content(json!({
2129 "results": results_json,
2130 "summary": {
2131 "total": outcomes.len(),
2132 "succeeded": success_count,
2133 "failed": failure_count,
2134 "concurrency": concurrency,
2135 }
2136 }))
2137 }
2138
2139 #[tool(
2142 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."
2143 )]
2144 fn load_iceberg(
2145 &self,
2146 Parameters(params): Parameters<LoadIcebergParams>,
2147 ) -> Result<CallToolResult, rmcp::ErrorData> {
2148 if let Err(e) = self.check_writable("load_iceberg") {
2149 return Self::err_content(e);
2150 }
2151 let table_name = params.table.clone();
2152 let mode = params.mode.clone().unwrap_or_else(|| "replace".into());
2153 let opts = crate::lakehouse::IcebergIngestOptions {
2154 table: params.table.clone(),
2155 mode: mode.clone(),
2156 metadata_filename: params.metadata_filename.clone(),
2157 version_as_of: params.version_as_of,
2158 };
2159
2160 let result = self.with_engine(|engine| {
2161 crate::attach::validate_input_path(¶ms.path, "iceberg table")?;
2163 let ingest_result =
2164 crate::lakehouse::ingest_iceberg_table(engine, ¶ms.path, &opts)?;
2165
2166 let schema_json: Vec<Value> = ingest_result
2167 .schema
2168 .iter()
2169 .map(|c| {
2170 json!({
2171 "name": c.name,
2172 "type": c.hyper_type,
2173 "nullable": c.nullable,
2174 })
2175 })
2176 .collect();
2177
2178 let load_params = serde_json::to_string(&json!({
2179 "source_path": params.path,
2180 "mode": mode,
2181 "format": "iceberg",
2182 "metadata_filename": params.metadata_filename,
2183 "version_as_of": params.version_as_of,
2184 }))
2185 .ok();
2186 self.after_ingest_catalog_update(
2187 engine,
2188 ¶ms.table,
2189 "load_iceberg",
2190 load_params.as_deref(),
2191 i64::try_from(ingest_result.rows).ok(),
2192 None,
2193 );
2194
2195 Ok(json!({
2196 "rows": ingest_result.rows,
2197 "schema": schema_json,
2198 "stats": ingest_result.stats.to_json(),
2199 }))
2200 });
2201
2202 match result {
2203 Ok(val) => {
2204 self.notify_table_changed(&table_name);
2205 if mode == "replace" {
2206 self.notify_resource_list_changed();
2207 }
2208 Self::ok_content(val)
2209 }
2210 Err(e) => Self::err_content(e),
2211 }
2212 }
2213
2214 #[tool(
2216 description = "Run a read-only SQL query (SELECT, WITH, EXPLAIN, SHOW, VALUES) against the workspace. For DDL/DML use the execute tool."
2217 )]
2218 fn query(
2219 &self,
2220 Parameters(params): Parameters<QueryParams>,
2221 ) -> Result<CallToolResult, rmcp::ErrorData> {
2222 let result = self.with_engine(|engine| {
2223 if !is_read_only_sql(¶ms.sql) {
2224 return Err(McpError::new(
2225 ErrorCode::SqlError,
2226 "The query tool only accepts read-only SQL (SELECT, WITH, EXPLAIN, SHOW, VALUES). Use the execute tool for DDL/DML.",
2227 ));
2228 }
2229 let target_db = self.resolve_db(engine, params.database.as_deref(), None, false)?;
2232 let _search_guard = match target_db {
2233 Some(ref alias) => Some(engine.scoped_search_path(alias)?),
2234 None => None,
2235 };
2236 const MAX_QUERY_ROWS: usize = 10_000;
2240
2241 let timer = crate::stats::StatsTimer::start();
2242 let mut rows = engine.execute_query_to_json(¶ms.sql)?;
2243 let total_rows = rows.len();
2244 let truncated = total_rows > MAX_QUERY_ROWS;
2245 if truncated {
2246 rows.truncate(MAX_QUERY_ROWS);
2247 }
2248 let elapsed = timer.elapsed_ms();
2249 let stats = crate::stats::QueryStats {
2250 operation: "query".into(),
2251 rows_returned: rows.len() as u64,
2252 rows_scanned: 0,
2253 elapsed_ms: elapsed,
2254 result_size_bytes: serde_json::to_string(&rows).map_or(0, |s| s.len() as u64),
2255 tables_touched: vec![],
2256 };
2257 let payload = if truncated {
2258 json!({
2259 "result": rows,
2260 "stats": stats.to_json(),
2261 "truncated": true,
2262 "total_rows": total_rows,
2263 "rows_returned": MAX_QUERY_ROWS,
2264 "hint": format!(
2265 "Result set has {total_rows} rows; only the first {MAX_QUERY_ROWS} \
2266 are shown. Add a LIMIT clause, aggregate with GROUP BY, or use \
2267 the `export` tool to write the full result to a file."
2268 ),
2269 })
2270 } else {
2271 json!({
2272 "result": rows,
2273 "stats": stats.to_json(),
2274 })
2275 };
2276 Ok((params.sql.clone(), payload))
2277 });
2278
2279 match result {
2280 Ok((sql, val)) => {
2281 let formatted_sql = Self::fmt_sql(&sql);
2282 let json_text = serde_json::to_string_pretty(&val).unwrap_or_default();
2283 Ok(CallToolResult::success(vec![
2284 Content::text(format!("```sql\n{formatted_sql}\n```")),
2285 Content::text(json_text),
2286 ]))
2287 }
2288 Err(e) => Self::err_content(e),
2289 }
2290 }
2291
2292 #[tool(
2294 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."
2295 )]
2296 fn execute(
2297 &self,
2298 Parameters(params): Parameters<ExecuteParams>,
2299 ) -> Result<CallToolResult, rmcp::ErrorData> {
2300 if let Err(e) = self.check_writable("execute") {
2301 return Self::err_content(e);
2302 }
2303 if let Err(e) = validate_execute_batch(¶ms.sql) {
2308 return Self::err_content(e);
2309 }
2310 let any_structural = params
2311 .sql
2312 .iter()
2313 .any(|s| matches!(classify_statement(s), StatementKind::Ddl));
2314 let result = self.with_engine(|engine| {
2315 let target_db = self.resolve_db(engine, params.database.as_deref(), None, true)?;
2319 let _search_guard = match target_db {
2320 Some(ref alias) => Some(engine.scoped_search_path(alias)?),
2321 None => None,
2322 };
2323 let total_timer = crate::stats::StatsTimer::start();
2324 let (per_statement, affected_total, operation): (Vec<Value>, u64, &'static str) =
2325 if params.sql.len() == 1 {
2326 let stmt = ¶ms.sql[0];
2330 let t = crate::stats::StatsTimer::start();
2331 let affected = engine.execute_command(stmt)?;
2332 (
2333 vec![json!({
2334 "sql": Self::fmt_sql(stmt),
2335 "affected_rows": affected,
2336 "elapsed_ms": t.elapsed_ms(),
2337 })],
2338 affected,
2339 "command",
2340 )
2341 } else {
2342 let stmts = ¶ms.sql;
2343 let (results, total) = engine.execute_in_transaction(|engine| {
2344 let mut out = Vec::with_capacity(stmts.len());
2345 let mut total: u64 = 0;
2346 for (idx, stmt) in stmts.iter().enumerate() {
2347 let t = crate::stats::StatsTimer::start();
2348 let affected = engine.execute_command(stmt).map_err(|e| {
2349 let rollback_note = format!(
2354 "Failing SQL: {sql}. All previous statements in this batch were rolled back.",
2355 sql = Self::fmt_sql(stmt)
2356 );
2357 let combined = match e.suggestion.as_deref() {
2358 Some(orig) => format!("{orig} | {rollback_note}"),
2359 None => rollback_note,
2360 };
2361 McpError::new(
2362 e.code,
2363 format!(
2364 "statement {} of {} failed: {}",
2365 idx + 1,
2366 stmts.len(),
2367 e.message
2368 ),
2369 )
2370 .with_suggestion(combined)
2371 })?;
2372 total = total.saturating_add(affected);
2376 out.push(json!({
2377 "sql": Self::fmt_sql(stmt),
2378 "affected_rows": affected,
2379 "elapsed_ms": t.elapsed_ms(),
2380 }));
2381 }
2382 Ok((out, total))
2383 })?;
2384 (results, total, "transaction")
2385 };
2386 let elapsed = total_timer.elapsed_ms();
2387 if any_structural {
2400 self.after_execute_catalog_update(engine, target_db.as_deref());
2401 }
2402 Ok(json!({
2403 "statements": per_statement.len(),
2404 "affected_rows": affected_total,
2405 "per_statement": per_statement,
2406 "stats": { "operation": operation, "elapsed_ms": elapsed },
2407 }))
2408 });
2409
2410 match result {
2411 Ok(val) => {
2412 self.notify_workspace_changed();
2417 if any_structural {
2418 self.notify_resource_list_changed();
2419 }
2420 Self::ok_content(val)
2421 }
2422 Err(e) => Self::err_content(e),
2423 }
2424 }
2425
2426 #[tool(
2428 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."
2429 )]
2430 fn sample(
2431 &self,
2432 Parameters(params): Parameters<SampleParams>,
2433 ) -> Result<CallToolResult, rmcp::ErrorData> {
2434 let result = self.with_engine(|engine| {
2435 let target_db = self.resolve_db(engine, params.database.as_deref(), None, false)?;
2436 let timer = crate::stats::StatsTimer::start();
2437 let n = params.n.unwrap_or(5);
2438 let mut sample = engine.sample_table_in(target_db.as_deref(), ¶ms.table, n)?;
2439 let elapsed = timer.elapsed_ms();
2440 if let Some(obj) = sample.as_object_mut() {
2441 obj.insert(
2442 "stats".into(),
2443 json!({ "operation": "sample", "elapsed_ms": elapsed }),
2444 );
2445 }
2446 Ok(sample)
2447 });
2448
2449 match result {
2450 Ok(val) => Self::ok_content(val),
2451 Err(e) => Self::err_content(e),
2452 }
2453 }
2454
2455 #[tool(
2457 description = "Render a chart (bar, line, scatter, or histogram) from a SQL query. Returns the PNG/SVG image inline by default so MCP clients can display it directly. Set `inline=false` to skip the inline bytes and write to disk only (keeps the MCP transcript small for batch workflows). Combine `inline=true` with `output_path` to get both.\n\n**Data shape:** The query must return long-format data with one numeric `y` column. For multi-series charts, use a `series` column to split by category. If your data is wide-format (multiple value columns), reshape it with `UNION ALL` into (label, series, value) tuples before charting.\n\n**DATE/TIMESTAMP x-axis:** Line and scatter charts auto-detect non-numeric x columns. DATE, TIMESTAMP, and TIMESTAMPTZ values render with a **proportional time axis** — gaps between data points reflect real wall-clock time (4.5 h gap and 17 h gap don't look the same). Tick labels are formatted in the input kind: `%Y-%m-%d` for DATE, `%Y-%m-%d %H:%M:%S` for TIMESTAMP, with the originating timezone offset preserved for TIMESTAMPTZ. TEXT x columns fall back to evenly-spaced categorical mode. Set `x_as_category: true` to force categorical layout on temporal data (useful when even spacing reads better than proportional gaps).\n\n- `output_path`: explicit destination file path. Parent directory is created automatically (no need to pre-create it). If omitted and `inline=true` (default), no file is written. If omitted and `inline=false`, a file is auto-generated under the system temp dir as `hyperdb-charts/chart-<ts>-<pid>-<n>.<ext>`.\n- `inline`: when true (default), return the image bytes inline. Without `output_path`, suppresses the disk write entirely. With `output_path`, writes to disk AND returns inline. Set to false for disk-only output.\n- `format`: \"png\" (default) or \"svg\". Auto-derived from `output_path` extension when omitted. A mismatch between `format` and the path extension returns `INVALID_ARGUMENT`.\n- `overwrite`: default true. Set false to refuse overwriting an existing file (returns `PERMISSION_DENIED`).\n- `x_range` / `y_range`: fix axis extents across multiple charts (e.g. x_range=[0,1500], y_range=[0,1]).\n- `color_map`: stable per-series hex colors (e.g. {\"India\":\"#e41a1c\",\"China\":\"#ff7f0e\"}).\n- `label_points=true`: annotate each point with its series name instead of showing a legend — best when each series has exactly one point."
2458 )]
2459 fn chart(
2460 &self,
2461 Parameters(params): Parameters<ChartParams>,
2462 ) -> Result<CallToolResult, rmcp::ErrorData> {
2463 let result = self.with_engine(|engine| {
2464 if !is_read_only_sql(¶ms.sql) {
2465 return Err(McpError::new(
2466 ErrorCode::SqlError,
2467 "The chart tool only accepts read-only SQL (SELECT, WITH, EXPLAIN, SHOW, VALUES).",
2468 ));
2469 }
2470
2471 if let Some(out) = params.output_path.as_deref() {
2474 crate::attach::validate_output_path(out, "chart output")?;
2475 }
2476 let format = crate::chart::resolve_chart_format(
2479 params.format.as_deref(),
2480 params.output_path.as_deref(),
2481 )?;
2482
2483 let target_db = self.resolve_db(engine, params.database.as_deref(), None, false)?;
2486 let _search_guard = match target_db {
2487 Some(ref alias) => Some(engine.scoped_search_path(alias)?),
2488 None => None,
2489 };
2490
2491 let timer = crate::stats::StatsTimer::start();
2492 let rows = engine.execute_query_to_json(¶ms.sql)?;
2493
2494 let color_map = params
2497 .color_map
2498 .as_ref()
2499 .map(|m| {
2500 m.iter()
2501 .filter_map(|(k, v)| {
2502 crate::chart::parse_hex_color(v)
2503 .map(|c| (k.clone(), c))
2504 })
2505 .collect::<std::collections::HashMap<_, _>>()
2506 })
2507 .unwrap_or_default();
2508
2509 let opts = ChartOptions {
2510 chart_type: ChartType::parse(¶ms.chart_type)?,
2511 x_column: params.x.clone(),
2512 y_column: params.y.clone(),
2513 series_column: params.series.clone(),
2514 title: params.title.clone(),
2515 format,
2516 width: params.width.unwrap_or(800).clamp(200, 4096),
2517 height: params.height.unwrap_or(480).clamp(150, 4096),
2518 bins: params.bins.unwrap_or(20).clamp(1, 500),
2519 x_as_category: params.x_as_category,
2520 x_range: params.x_range,
2521 y_range: params.y_range,
2522 color_map,
2523 label_points: params.label_points.unwrap_or(false),
2524 };
2525
2526 let chart = render_chart(&rows, &opts)?;
2527
2528 let disposition = crate::chart::resolve_chart_disposition(
2532 params.inline.unwrap_or(true),
2533 params.output_path.as_deref(),
2534 opts.format,
2535 );
2536 let overwrite = params.overwrite.unwrap_or(true);
2537 if let Some(path) = disposition.path() {
2538 crate::chart::write_chart_to_disk(path, &chart.bytes, overwrite)?;
2539 }
2540
2541 let elapsed = timer.elapsed_ms();
2542 Ok((chart, elapsed, opts, disposition))
2543 });
2544
2545 match result {
2546 Ok((chart, elapsed_ms, opts, disposition)) => {
2547 let format_str = match opts.format {
2548 ChartFormat::Png => "png",
2549 ChartFormat::Svg => "svg",
2550 };
2551 let wants_inline = disposition.wants_inline();
2552 let output_path_str = disposition.path().map(|p| p.to_string_lossy().into_owned());
2553
2554 let mut stats = serde_json::Map::new();
2555 stats.insert("operation".into(), json!("chart"));
2556 stats.insert("rows_plotted".into(), json!(chart.rows_plotted));
2557 stats.insert("elapsed_ms".into(), json!(elapsed_ms));
2558 stats.insert("format".into(), json!(format_str));
2559 stats.insert("bytes".into(), json!(chart.bytes.len()));
2560 stats.insert("width".into(), json!(opts.width));
2561 stats.insert("height".into(), json!(opts.height));
2562 stats.insert("inline".into(), json!(wants_inline));
2563 if let Some(p) = output_path_str {
2564 stats.insert("output_path".into(), json!(p));
2565 }
2566 let stats_text =
2567 serde_json::to_string_pretty(&Value::Object(stats)).unwrap_or_default();
2568
2569 let mut content = Vec::with_capacity(2);
2570 if wants_inline {
2571 let b64 = base64::engine::general_purpose::STANDARD.encode(&chart.bytes);
2572 content.push(Content::image(b64, chart.mime_type.to_string()));
2573 }
2574 content.push(Content::text(stats_text));
2575 Ok(CallToolResult::success(content))
2576 }
2577 Err(e) => Self::err_content(e),
2578 }
2579 }
2580
2581 #[tool(
2584 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."
2585 )]
2586 fn watch_directory(
2587 &self,
2588 Parameters(params): Parameters<WatchDirectoryParams>,
2589 ) -> Result<CallToolResult, rmcp::ErrorData> {
2590 if let Err(e) = self.check_writable("watch_directory") {
2591 return Self::err_content(e);
2592 }
2593 let canonical = match crate::attach::validate_input_path(¶ms.path, "watch directory") {
2594 Ok(p) => p,
2595 Err(e) => return Self::err_content(e),
2596 };
2597 match self.ensure_engine() {
2600 Ok(guard) => drop(guard),
2601 Err(e) => return Self::err_content(e),
2602 }
2603
2604 let target_db = match self.with_engine(|engine| {
2608 self.resolve_db(engine, params.database.as_deref(), params.persist, true)
2609 }) {
2610 Ok(v) => v,
2611 Err(e) => return Self::err_content(e),
2612 };
2613
2614 let path = canonical;
2615 let engine_handle = self.engine_handle();
2616 let attachments = self.attachments_handle();
2617 let registry = self.watchers_handle();
2618 let options = crate::watcher::WatchOptions {
2619 max_concurrent: params.max_concurrent.unwrap_or(0) as usize,
2620 };
2621 let result = crate::watcher::start_watching(
2622 engine_handle,
2623 attachments,
2624 registry,
2625 Some(self.subscriptions_handle()),
2626 path.clone(),
2627 params.table.clone(),
2628 target_db,
2629 options,
2630 );
2631 match result {
2632 Ok(stats) => {
2633 let body = json!({
2634 "directory": path.to_string_lossy(),
2635 "table": params.table,
2636 "status": "watching",
2637 "max_concurrent": stats.max_concurrent,
2638 "initial_sweep": {
2639 "files_ingested": stats.files_ingested,
2640 "files_failed": stats.files_failed,
2641 },
2642 });
2643 Self::ok_content(body)
2644 }
2645 Err(e) => Self::err_content(e),
2646 }
2647 }
2648
2649 #[tool(
2651 description = "Stop watching a directory previously registered with watch_directory. Pending .ready files are left in place."
2652 )]
2653 fn unwatch_directory(
2654 &self,
2655 Parameters(params): Parameters<UnwatchDirectoryParams>,
2656 ) -> Result<CallToolResult, rmcp::ErrorData> {
2657 let path = std::path::PathBuf::from(¶ms.path);
2658 let result = crate::watcher::stop_watching(&self.watchers_handle(), &path);
2659 match result {
2660 Ok(summary) => Self::ok_content(summary),
2661 Err(e) => Self::err_content(e),
2662 }
2663 }
2664
2665 #[tool(
2668 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."
2669 )]
2670 fn describe(
2671 &self,
2672 Parameters(params): Parameters<DescribeParams>,
2673 ) -> Result<CallToolResult, rmcp::ErrorData> {
2674 let result = self.with_engine(|engine| {
2675 let target_db = self.resolve_db(engine, params.database.as_deref(), None, false)?;
2676 match params.table.as_deref() {
2677 Some(name) => engine
2678 .describe_table_in(target_db.as_deref(), name)
2679 .map(|t| vec![t]),
2680 None => engine.describe_tables_in(target_db.as_deref()),
2681 }
2682 });
2683
2684 match result {
2685 Ok(tables) => Self::ok_content(json!({"tables": tables})),
2686 Err(e) => Self::err_content(e),
2687 }
2688 }
2689
2690 #[tool(
2695 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)."
2696 )]
2697 #[expect(
2698 clippy::unused_self,
2699 reason = "method retained on the type for API symmetry; implementation currently does not need state"
2700 )]
2701 fn inspect_file(
2702 &self,
2703 Parameters(params): Parameters<InspectFileParams>,
2704 ) -> Result<CallToolResult, rmcp::ErrorData> {
2705 if let Err(e) = crate::attach::validate_input_path(¶ms.path, "data file") {
2706 return Self::err_content(e);
2707 }
2708 let sample_rows = params.sample_rows.unwrap_or(5).clamp(1, 50) as usize;
2709 let result = if let Some(ref json_path) = params.json_extract_path {
2710 (|| -> Result<_, McpError> {
2711 let raw = std::fs::read_to_string(¶ms.path).map_err(|e| {
2712 McpError::new(
2713 ErrorCode::FileNotFound,
2714 format!("Cannot read file '{}': {e}", params.path),
2715 )
2716 })?;
2717 let file_size = std::fs::metadata(¶ms.path).map_or(0, |m| m.len());
2718 let extracted = crate::ingest::extract_json_path(&raw, json_path)?;
2719 crate::inspect::inspect_json_from_text(&extracted, file_size, sample_rows)
2720 })()
2721 } else {
2722 crate::inspect::inspect_source(¶ms.path, sample_rows)
2723 };
2724 match result {
2725 Ok(report) => Self::ok_content(report.to_json()),
2726 Err(e) => Self::err_content(e),
2727 }
2728 }
2729
2730 #[tool(
2733 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)."
2734 )]
2735 fn export(
2736 &self,
2737 Parameters(params): Parameters<ExportParams>,
2738 ) -> Result<CallToolResult, rmcp::ErrorData> {
2739 let result = self.with_engine(|engine| {
2740 crate::attach::validate_output_path(¶ms.path, "export")?;
2743 let format_options = match params.format_options.clone() {
2747 None => None,
2748 Some(Value::Object(m)) => Some(m),
2749 Some(other) => {
2750 return Err(McpError::new(
2751 ErrorCode::SchemaMismatch,
2752 format!("export: format_options must be a JSON object, got: {other}"),
2753 ));
2754 }
2755 };
2756 let target_db = self.resolve_db(engine, params.database.as_deref(), None, false)?;
2767 let (effective_sql, effective_table) = match (¶ms.sql, ¶ms.table, &target_db) {
2768 (None, Some(t), Some(db)) => {
2769 let esc_db = db.replace('"', "\"\"");
2770 let esc_tbl = t.replace('"', "\"\"");
2771 (
2772 Some(format!(
2773 "SELECT * FROM \"{esc_db}\".\"public\".\"{esc_tbl}\""
2774 )),
2775 None,
2776 )
2777 }
2778 _ => (params.sql.clone(), params.table.clone()),
2779 };
2780 let _search_guard = match (&effective_sql, &target_db, ¶ms.sql) {
2781 (Some(_), Some(alias), Some(_)) => Some(engine.scoped_search_path(alias)?),
2784 _ => None,
2785 };
2786 let opts = ExportOptions {
2787 sql: effective_sql,
2788 table: effective_table,
2789 path: params.path,
2790 format: params.format,
2791 overwrite: params.overwrite.unwrap_or(true),
2792 format_options,
2793 source_db: target_db.clone(),
2794 };
2795 let export_result = export_to_file(engine, &opts)?;
2796 Ok(json!({
2797 "output_path": export_result.stats.output_path,
2798 "rows": export_result.rows,
2799 "file_size_bytes": export_result.stats.file_size_bytes,
2800 "stats": export_result.stats.to_json(),
2801 }))
2802 });
2803
2804 match result {
2805 Ok(val) => Self::ok_content(val),
2806 Err(e) => Self::err_content(e),
2807 }
2808 }
2809
2810 #[tool(
2814 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."
2815 )]
2816 fn save_query(
2817 &self,
2818 Parameters(params): Parameters<SaveQueryParams>,
2819 ) -> Result<CallToolResult, rmcp::ErrorData> {
2820 if let Err(e) = self.check_writable("save_query") {
2821 return Self::err_content(e);
2822 }
2823 if !is_read_only_sql(¶ms.sql) {
2828 return Self::err_content(McpError::new(
2829 ErrorCode::SqlError,
2830 "save_query only accepts read-only SQL (SELECT / WITH / EXPLAIN / SHOW / VALUES). \
2831 Use the execute tool for DDL/DML, not save_query.",
2832 ));
2833 }
2834 if params.name.is_empty() {
2835 return Self::err_content(McpError::new(
2836 ErrorCode::SchemaMismatch,
2837 "Saved query name must not be empty.",
2838 ));
2839 }
2840 let query = SavedQuery {
2841 name: params.name.clone(),
2842 sql: params.sql,
2843 description: params.description,
2844 created_at: chrono::Utc::now(),
2845 };
2846 let store = Arc::clone(&self.saved_queries);
2847 let result = self.with_saved_query_store(|engine| store.save(engine, query.clone()));
2848 match result {
2849 Ok(()) => {
2850 self.notify_resource_list_changed();
2854 Self::ok_content(json!({
2855 "saved": true,
2856 "name": query.name,
2857 "resources": [
2858 format!("hyper://queries/{}/definition", query.name),
2859 format!("hyper://queries/{}/result", query.name),
2860 ],
2861 "created_at": query.created_at.to_rfc3339(),
2862 }))
2863 }
2864 Err(e) => Self::err_content(e),
2865 }
2866 }
2867
2868 #[tool(
2870 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)."
2871 )]
2872 fn delete_query(
2873 &self,
2874 Parameters(params): Parameters<DeleteQueryParams>,
2875 ) -> Result<CallToolResult, rmcp::ErrorData> {
2876 if let Err(e) = self.check_writable("delete_query") {
2877 return Self::err_content(e);
2878 }
2879 let store = Arc::clone(&self.saved_queries);
2880 let name = params.name.clone();
2881 let result = self.with_saved_query_store(|engine| store.delete(engine, &name));
2882 match result {
2883 Ok(deleted) => {
2884 if deleted {
2885 self.notify_resource_list_changed();
2890 self.subscriptions
2891 .notify_updated(&format!("hyper://queries/{name}/definition"));
2892 self.subscriptions
2893 .notify_updated(&format!("hyper://queries/{name}/result"));
2894 }
2895 Self::ok_content(json!({
2896 "deleted": deleted,
2897 "name": params.name,
2898 }))
2899 }
2900 Err(e) => Self::err_content(e),
2901 }
2902 }
2903
2904 #[tool(
2906 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."
2907 )]
2908 fn set_table_metadata(
2909 &self,
2910 Parameters(params): Parameters<SetTableMetadataParams>,
2911 ) -> Result<CallToolResult, rmcp::ErrorData> {
2912 if let Err(e) = self.check_writable("set_table_metadata") {
2913 return Self::err_content(e);
2914 }
2915 let fields = crate::table_catalog::MetadataFields {
2916 source_url: params.source_url,
2917 source_description: params.source_description,
2918 purpose: params.purpose,
2919 license: params.license,
2920 notes: params.notes,
2921 };
2922 let table_name = params.table.clone();
2923 let result = self.with_engine(|engine| {
2924 let target_db = self.resolve_db(engine, params.database.as_deref(), None, true)?;
2930 crate::table_catalog::set_metadata_in(
2931 engine,
2932 &table_name,
2933 &fields,
2934 target_db.as_deref(),
2935 )
2936 });
2937 match result {
2938 Ok(entry) => Self::ok_content(entry.to_json()),
2939 Err(e) => Self::err_content(e),
2940 }
2941 }
2942
2943 #[tool(
2947 description = "Returns plugin health, workspace info, table count, total rows, disk usage, the backing hyperd connection (engine.mode, engine.hyperd_endpoint, engine.daemon_health_port), and active directory watchers."
2948 )]
2949 fn status(&self) -> Result<CallToolResult, rmcp::ErrorData> {
2950 let Ok(guard) = self.engine.try_lock() else {
2955 return Self::ok_content(self.status_degraded());
2959 };
2960 let Some(engine) = guard.as_ref() else {
2961 return Self::ok_content(self.status_degraded());
2966 };
2967 self.ensure_catalog_ready(engine);
2968 let result = engine.status();
2969
2970 match result {
2971 Ok(mut val) => {
2972 if let Some(obj) = val.as_object_mut() {
2973 obj.insert("engine_busy".into(), json!(false));
2974 obj.insert("watchers".into(), self.watchers.to_json());
2975 obj.insert("read_only".into(), json!(self.read_only));
2976 let attachments: Vec<Value> = self
2977 .attachments
2978 .list()
2979 .iter()
2980 .map(super::attach::AttachedDb::to_json)
2981 .collect();
2982 obj.insert("attachments".into(), Value::Array(attachments));
2983 }
2984 Self::ok_content(val)
2985 }
2986 Err(e) => Self::err_content(e),
2987 }
2988 }
2989
2990 #[tool(
2995 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."
2996 )]
2997 #[expect(
2998 clippy::unused_self,
2999 reason = "the #[tool] macro dispatches on &self; signature must match the rest of the tool surface even though this tool is stateless"
3000 )]
3001 #[expect(
3002 clippy::unnecessary_wraps,
3003 reason = "uniform Result<CallToolResult, rmcp::ErrorData> across all tools so the #[tool_router] dispatcher has one signature shape"
3004 )]
3005 fn get_readme(&self) -> Result<CallToolResult, rmcp::ErrorData> {
3006 Ok(CallToolResult::success(vec![Content::text(
3007 crate::readme::README,
3008 )]))
3009 }
3010
3011 #[tool(
3014 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."
3015 )]
3016 fn attach_database(
3017 &self,
3018 Parameters(params): Parameters<AttachDatabaseParams>,
3019 ) -> Result<CallToolResult, rmcp::ErrorData> {
3020 let writable = params.writable.unwrap_or(false);
3021 if writable {
3022 if let Err(e) = self.check_writable("attach_database(writable)") {
3023 return Self::err_content(e);
3024 }
3025 }
3026 let on_missing = match attach::OnMissing::parse(params.on_missing.as_deref()) {
3027 Ok(v) => v,
3028 Err(e) => return Self::err_content(e),
3029 };
3030 if on_missing == attach::OnMissing::Create && !writable {
3031 return Self::err_content(McpError::new(
3032 ErrorCode::InvalidArgument,
3033 "on_missing='create' requires writable:true — an empty .hyper file that cannot be written to cannot be populated.",
3034 ));
3035 }
3036 let source = match params.kind.as_str() {
3037 "local_file" => {
3038 let Some(raw) = params.path.as_deref() else {
3039 return Self::err_content(McpError::new(
3040 ErrorCode::InvalidArgument,
3041 "kind='local_file' requires a 'path' argument",
3042 ));
3043 };
3044 let resolved = match on_missing {
3045 attach::OnMissing::Error => attach::validate_local_path(raw),
3046 attach::OnMissing::Create => attach::validate_local_path_for_create(raw),
3047 };
3048 match resolved {
3049 Ok(canonical) => AttachSource::LocalFile { path: canonical },
3050 Err(e) => return Self::err_content(e),
3051 }
3052 }
3053 other => {
3054 return Self::err_content(McpError::new(
3055 ErrorCode::InvalidArgument,
3056 format!(
3057 "Unsupported attach kind '{other}'. Only 'local_file' is supported today; \
3058 'tcp' (remote hyperd) and 'grpc' (Data 360) are planned."
3059 ),
3060 ));
3061 }
3062 };
3063 let req = AttachRequest {
3064 alias: params.alias.clone(),
3065 source,
3066 writable,
3067 on_missing,
3068 };
3069 let registry = self.attachments_handle();
3070 let alias_for_probe = req.alias.clone();
3071 let result = self.with_engine(|engine| {
3072 let entry = registry.attach(engine, req.clone())?;
3073 let tables_visible = probe_table_count(engine, &alias_for_probe);
3078 Ok(json!({
3079 "alias": entry.alias,
3080 "kind": entry.source.kind_str(),
3081 "source": entry.source.to_json(),
3082 "writable": entry.writable,
3083 "tables_visible": tables_visible,
3084 }))
3085 });
3086 match result {
3087 Ok(val) => Self::ok_content(val),
3088 Err(e) => Self::err_content(e),
3089 }
3090 }
3091
3092 #[tool(
3094 description = "Detach a database previously registered with attach_database. No-op when the alias is unknown. Returns {detached: true/false}."
3095 )]
3096 fn detach_database(
3097 &self,
3098 Parameters(params): Parameters<DetachDatabaseParams>,
3099 ) -> Result<CallToolResult, rmcp::ErrorData> {
3100 let alias = params.alias.to_ascii_lowercase();
3105 if let Ok(watchers) = self.watchers.watchers.lock() {
3111 let conflict = watchers
3112 .values()
3113 .find(|h| h.target_db.as_deref() == Some(alias.as_str()));
3114 if let Some(h) = conflict {
3115 return Self::err_content(McpError::new(
3116 ErrorCode::InvalidArgument,
3117 format!(
3118 "cannot detach '{alias}': an active watcher on directory '{}' targets it. \
3119 Call unwatch_directory(\"{}\") first.",
3120 h.directory.display(),
3121 h.directory.display()
3122 ),
3123 ));
3124 }
3125 }
3126 let registry = self.attachments_handle();
3127 let result = self.with_engine(|engine| {
3128 let outcome = registry.detach(engine, &alias)?;
3129 if outcome {
3130 engine.clear_catalog_cache_for(&alias);
3134 }
3135 Ok(outcome)
3136 });
3137 match result {
3138 Ok(detached) => {
3139 Self::ok_content(json!({ "alias": params.alias, "detached": detached }))
3140 }
3141 Err(e) => Self::err_content(e),
3142 }
3143 }
3144
3145 #[tool(
3155 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."
3156 )]
3157 fn list_attached_databases(&self) -> Result<CallToolResult, rmcp::ErrorData> {
3158 let result = self.with_engine(|engine| {
3159 let entries = self.attachments.list();
3160 let attachments: Vec<Value> = entries
3161 .iter()
3162 .map(|entry| {
3163 let mut obj = entry.to_json();
3164 let tables_visible = probe_table_count(engine, &entry.alias);
3165 if let Some(map) = obj.as_object_mut() {
3166 map.insert("tables_visible".into(), json!(tables_visible));
3167 }
3168 obj
3169 })
3170 .collect();
3171 Ok(json!({ "attachments": attachments }))
3172 });
3173 match result {
3174 Ok(val) => Self::ok_content(val),
3175 Err(e) => Self::err_content(e),
3176 }
3177 }
3178
3179 #[tool(
3184 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."
3185 )]
3186 fn copy_query(
3187 &self,
3188 Parameters(params): Parameters<CopyQueryParams>,
3189 ) -> Result<CallToolResult, rmcp::ErrorData> {
3190 if let Err(e) = self.check_writable("copy_query") {
3191 return Self::err_content(e);
3192 }
3193 let mode = match params.mode.as_str() {
3194 "create" | "append" | "replace" => params.mode.clone(),
3195 other => {
3196 return Self::err_content(McpError::new(
3197 ErrorCode::InvalidArgument,
3198 format!(
3199 "copy_query mode '{other}' is not supported. Use 'create', 'append', or 'replace'."
3200 ),
3201 ));
3202 }
3203 };
3204 if !is_read_only_sql(¶ms.sql) {
3205 return Self::err_content(McpError::new(
3206 ErrorCode::SqlError,
3207 "copy_query's `sql` must be a read-only statement (SELECT / WITH / VALUES). \
3208 Use the execute tool for raw DDL/DML.",
3209 ));
3210 }
3211 let target_db_owned = params
3223 .target_database
3224 .as_deref()
3225 .filter(|s| !s.eq_ignore_ascii_case(LOCAL_ALIAS))
3226 .map(str::to_ascii_lowercase);
3227 let target_db = target_db_owned.as_deref();
3228 if let Some(alias) = target_db {
3229 match self.attachments.get(alias) {
3230 None => {
3231 return Self::err_content(McpError::new(
3232 ErrorCode::InvalidArgument,
3233 format!(
3234 "target_database '{alias}' is not attached. Call attach_database first."
3235 ),
3236 ));
3237 }
3238 Some(entry) if !entry.writable => {
3239 return Self::err_content(McpError::new(
3240 ErrorCode::InvalidArgument,
3241 format!(
3242 "target_database '{alias}' was attached read-only. Re-attach with writable:true to use it as a copy target."
3243 ),
3244 ));
3245 }
3246 Some(_) => {}
3247 }
3248 }
3249
3250 let temp_specs = params.temp_attach.clone().unwrap_or_default();
3253 let prepared_temps = match prepare_temp_attachments(&temp_specs, self.is_read_only()) {
3254 Ok(v) => v,
3255 Err(e) => return Self::err_content(e),
3256 };
3257
3258 let target_table = params.target_table.clone();
3259 let sql_body = params.sql.clone();
3260 let load_params = serde_json::to_string(&json!({
3261 "mode": mode,
3262 "target_database": params.target_database,
3263 "target_table": target_table,
3264 "sql": Self::fmt_sql(&sql_body),
3265 }))
3266 .ok();
3267
3268 let registry = self.attachments_handle();
3269 let result = self.with_engine(|engine| {
3270 let mut temp_aliases: Vec<String> = Vec::new();
3272 for req in &prepared_temps {
3273 match registry.attach(engine, req.clone()) {
3274 Ok(entry) => temp_aliases.push(entry.alias),
3275 Err(e) => {
3276 for alias in &temp_aliases {
3278 let _ = registry.detach(engine, alias);
3279 }
3280 return Err(e);
3281 }
3282 }
3283 }
3284
3285 let copy_outcome = perform_copy(engine, &mode, target_db, &target_table, &sql_body);
3288
3289 for alias in &temp_aliases {
3293 if let Err(e) = registry.detach(engine, alias) {
3294 tracing::warn!(
3295 alias = %alias,
3296 err = %e.message,
3297 "failed to detach temp attachment after copy_query",
3298 );
3299 }
3300 }
3301
3302 if copy_outcome.is_ok() && target_db.is_none() {
3313 let row_count = copy_outcome
3314 .as_ref()
3315 .ok()
3316 .and_then(|v| v.get("row_count").and_then(serde_json::Value::as_i64));
3317 self.after_ingest_catalog_update(
3318 engine,
3319 &target_table,
3320 "copy_query",
3321 load_params.as_deref(),
3322 row_count,
3323 target_db,
3324 );
3325 }
3326
3327 copy_outcome
3328 });
3329
3330 match result {
3331 Ok(outcome) => {
3332 if target_db.is_none() {
3334 self.notify_table_changed(&target_table);
3335 }
3336 self.notify_workspace_changed();
3337 if mode != "append" {
3338 self.notify_resource_list_changed();
3341 }
3342 Self::ok_content(outcome)
3343 }
3344 Err(e) => Self::err_content(e),
3345 }
3346 }
3347}
3348
3349#[prompt_router]
3352impl HyperMcpServer {
3353 #[prompt(
3355 name = "analyze-table",
3356 description = "Deep analysis of a single table: schema, sample, column stats, data quality"
3357 )]
3358 pub async fn analyze_table(
3359 &self,
3360 Parameters(args): Parameters<AnalyzeTableArgs>,
3361 ) -> Vec<PromptMessage> {
3362 let context = self.build_analyze_context(&args.table);
3363 vec![
3364 PromptMessage::new_text(
3365 PromptMessageRole::User,
3366 format!(
3367 "Analyze the `{}` table thoroughly.\n\n{}\n\nPlease:\n\
3368 1. Describe each column (what it likely represents based on name and sample values)\n\
3369 2. Compute basic statistics using the query tool: min/max/avg for numeric columns, distinct count and top values for text columns\n\
3370 3. Flag any data quality issues: unexpected NULLs, suspicious outliers, inconsistent formats\n\
3371 4. Summarize your findings in plain English",
3372 args.table, context
3373 ),
3374 ),
3375 PromptMessage::new_text(
3376 PromptMessageRole::Assistant,
3377 format!(
3378 "I'll analyze the `{}` table systematically. Let me start by examining the schema and sample, then run targeted queries for statistics and data quality.",
3379 args.table
3380 ),
3381 ),
3382 ]
3383 }
3384
3385 #[prompt(
3387 name = "compare-tables",
3388 description = "Compare two tables: schema alignment, common keys, JOIN opportunities"
3389 )]
3390 pub async fn compare_tables(
3391 &self,
3392 Parameters(args): Parameters<CompareTablesArgs>,
3393 ) -> Vec<PromptMessage> {
3394 let ctx_a = self.build_brief_context(&args.table_a);
3395 let ctx_b = self.build_brief_context(&args.table_b);
3396 vec![
3397 PromptMessage::new_text(
3398 PromptMessageRole::User,
3399 format!(
3400 "Compare these two tables:\n\n## Table A: `{}`\n{}\n\n## Table B: `{}`\n{}\n\nPlease:\n\
3401 1. Identify columns that appear in both tables (by name or semantic match)\n\
3402 2. Suggest likely JOIN keys and the JOIN type (inner, left, etc.)\n\
3403 3. Highlight schema differences (column types, nullability)\n\
3404 4. Propose 3-5 analytical queries that combine both tables and explain what each reveals",
3405 args.table_a, ctx_a, args.table_b, ctx_b
3406 ),
3407 ),
3408 PromptMessage::new_text(
3409 PromptMessageRole::Assistant,
3410 format!(
3411 "I'll compare `{}` and `{}` systematically — schema alignment first, then join keys, then analytical opportunities.",
3412 args.table_a, args.table_b
3413 ),
3414 ),
3415 ]
3416 }
3417
3418 #[prompt(
3420 name = "data-quality",
3421 description = "Systematic data quality assessment: NULL rates, duplicates, low cardinality, outliers"
3422 )]
3423 pub async fn data_quality(
3424 &self,
3425 Parameters(args): Parameters<DataQualityArgs>,
3426 ) -> Vec<PromptMessage> {
3427 let context = self.build_brief_context(&args.table);
3428 vec![
3429 PromptMessage::new_text(
3430 PromptMessageRole::User,
3431 format!(
3432 "Run a data quality assessment on the `{}` table.\n\n{}\n\nPlease use the query tool to check:\n\
3433 1. NULL rate per column — run SELECT COUNT(*) FILTER (WHERE col IS NULL) / COUNT(*) for each column\n\
3434 2. Duplicate rows — compare COUNT(*) vs COUNT(DISTINCT *) or use GROUP BY\n\
3435 3. Low-cardinality columns — columns with suspiciously few distinct values\n\
3436 4. Numeric outliers — values more than 3 stddev from the mean\n\
3437 5. Date sanity — future dates or impossibly old dates in date/timestamp columns\n\n\
3438 Summarize findings with severity (critical / warning / info) and suggest remediation for each issue.",
3439 args.table, context
3440 ),
3441 ),
3442 PromptMessage::new_text(
3443 PromptMessageRole::Assistant,
3444 format!(
3445 "I'll perform a systematic data quality assessment on `{}`. Let me run targeted queries for each check category.",
3446 args.table
3447 ),
3448 ),
3449 ]
3450 }
3451
3452 #[prompt(
3454 name = "suggest-queries",
3455 description = "Suggest analytical SQL queries for a table, optionally guided by a goal"
3456 )]
3457 pub async fn suggest_queries(
3458 &self,
3459 Parameters(args): Parameters<SuggestQueriesArgs>,
3460 ) -> Vec<PromptMessage> {
3461 let context = self.build_analyze_context(&args.table);
3462 let goal_section = match args.goal.as_deref() {
3463 Some(g) if !g.is_empty() => format!("\n\nSpecific goal: {g}"),
3464 _ => String::new(),
3465 };
3466 vec![
3467 PromptMessage::new_text(
3468 PromptMessageRole::User,
3469 format!(
3470 "Given the `{}` table:\n\n{}{}\n\nSuggest 5 analytical SQL queries that would be useful for exploring this data. \
3471 For each query, provide:\n\
3472 - A descriptive title\n\
3473 - The exact SQL (valid for Hyper / PostgreSQL-compatible syntax)\n\
3474 - One sentence explaining what insight it reveals\n\n\
3475 Prefer queries that use aggregations, GROUP BY, window functions, or CTEs to demonstrate the power of SQL analytics.",
3476 args.table, context, goal_section
3477 ),
3478 ),
3479 PromptMessage::new_text(
3480 PromptMessageRole::Assistant,
3481 format!(
3482 "Based on the schema and sample of `{}`, here are 5 analytical queries.",
3483 args.table
3484 ),
3485 ),
3486 ]
3487 }
3488}
3489
3490#[derive(Debug, Clone)]
3499pub enum ResourceBody {
3500 Json(Value),
3502 Text {
3505 mime_type: String,
3507 content: String,
3509 },
3510}
3511
3512impl ResourceBody {
3513 #[must_use]
3515 pub fn mime_type(&self) -> &str {
3516 match self {
3517 ResourceBody::Json(_) => "application/json",
3518 ResourceBody::Text { mime_type, .. } => mime_type,
3519 }
3520 }
3521
3522 #[must_use]
3525 pub fn to_text(&self) -> String {
3526 match self {
3527 ResourceBody::Json(v) => {
3528 serde_json::to_string_pretty(v).unwrap_or_else(|_| v.to_string())
3529 }
3530 ResourceBody::Text { content, .. } => content.clone(),
3531 }
3532 }
3533
3534 #[must_use]
3537 pub fn as_json(&self) -> Option<&Value> {
3538 match self {
3539 ResourceBody::Json(v) => Some(v),
3540 ResourceBody::Text { .. } => None,
3541 }
3542 }
3543}
3544
3545impl HyperMcpServer {
3546 pub fn resource_body_for_uri(&self, uri: &str) -> Result<Option<ResourceBody>, McpError> {
3565 if uri == "hyper://workspace" {
3566 return self
3567 .with_engine(super::engine::Engine::status)
3568 .map(|v| Some(ResourceBody::Json(v)));
3569 }
3570 if uri == "hyper://tables" {
3571 return self
3572 .with_engine(|engine| {
3573 engine
3574 .describe_tables()
3575 .map(|tables| json!({ "tables": tables }))
3576 })
3577 .map(|v| Some(ResourceBody::Json(v)));
3578 }
3579 if uri == "hyper://readme" {
3580 return self.build_readme_body().map(Some);
3581 }
3582 if let Some(name) = uri
3583 .strip_prefix("hyper://tables/")
3584 .and_then(|rest| rest.strip_suffix("/schema"))
3585 {
3586 let name = name.to_string();
3587 return self
3588 .with_engine(|engine| {
3589 let tables = engine.describe_tables()?;
3590 tables
3591 .into_iter()
3592 .find(|t| t.get("name").and_then(|v| v.as_str()) == Some(name.as_str()))
3593 .ok_or_else(|| {
3594 McpError::new(
3595 ErrorCode::TableNotFound,
3596 format!("Table '{name}' does not exist"),
3597 )
3598 })
3599 })
3600 .map(|v| Some(ResourceBody::Json(v)));
3601 }
3602 if let Some(name) = uri
3603 .strip_prefix("hyper://tables/")
3604 .and_then(|rest| rest.strip_suffix("/sample"))
3605 {
3606 let name = name.to_string();
3607 return self
3608 .with_engine(|engine| engine.sample_table(&name, TABLE_SAMPLE_ROWS))
3609 .map(|v| Some(ResourceBody::Json(v)));
3610 }
3611 if let Some(name) = uri
3612 .strip_prefix("hyper://tables/")
3613 .and_then(|rest| rest.strip_suffix("/csv-sample"))
3614 {
3615 let name = name.to_string();
3616 return self.build_csv_sample_body(&name).map(Some);
3617 }
3618 if let Some(name) = uri
3619 .strip_prefix("hyper://queries/")
3620 .and_then(|rest| rest.strip_suffix("/definition"))
3621 {
3622 return self.build_saved_query_definition(name).map(Some);
3623 }
3624 if let Some(name) = uri
3625 .strip_prefix("hyper://queries/")
3626 .and_then(|rest| rest.strip_suffix("/result"))
3627 {
3628 return self.build_saved_query_result(name).map(Some);
3629 }
3630 Ok(None)
3631 }
3632
3633 fn build_saved_query_definition(&self, name: &str) -> Result<ResourceBody, McpError> {
3637 let store = Arc::clone(&self.saved_queries);
3638 let name = name.to_string();
3639 let query = self.with_saved_query_store(|engine| store.get(engine, &name))?;
3640 match query {
3641 Some(q) => Ok(ResourceBody::Json(q.to_json())),
3642 None => Err(McpError::new(
3643 ErrorCode::TableNotFound,
3644 format!("No saved query named '{name}'"),
3645 )),
3646 }
3647 }
3648
3649 fn build_saved_query_result(&self, name: &str) -> Result<ResourceBody, McpError> {
3654 let store = Arc::clone(&self.saved_queries);
3655 let name_owned = name.to_string();
3656 let query = self
3657 .with_saved_query_store(|engine| store.get(engine, &name_owned))?
3658 .ok_or_else(|| {
3659 McpError::new(
3660 ErrorCode::TableNotFound,
3661 format!("No saved query named '{name_owned}'"),
3662 )
3663 })?;
3664 let sql = query.sql.clone();
3665 let body = self.with_engine(|engine| {
3666 let timer = crate::stats::StatsTimer::start();
3667 let rows = engine.execute_query_to_json(&sql)?;
3668 let elapsed = timer.elapsed_ms();
3669 let result_size = serde_json::to_string(&rows).map_or(0, |s| s.len() as u64);
3670 let stats = crate::stats::QueryStats {
3671 operation: "saved_query".into(),
3672 rows_returned: rows.len() as u64,
3673 rows_scanned: 0,
3674 elapsed_ms: elapsed,
3675 result_size_bytes: result_size,
3676 tables_touched: vec![],
3677 };
3678 Ok(json!({
3679 "name": query.name,
3680 "sql": Self::fmt_sql(&query.sql),
3681 "result": rows,
3682 "stats": stats.to_json(),
3683 }))
3684 })?;
3685 Ok(ResourceBody::Json(body))
3686 }
3687
3688 #[must_use]
3695 pub fn list_resource_uris(&self) -> Vec<String> {
3696 let mut uris = vec![
3697 "hyper://workspace".to_string(),
3698 "hyper://tables".to_string(),
3699 "hyper://readme".to_string(),
3700 ];
3701 if let Ok(tables) = self.with_engine(super::engine::Engine::describe_tables) {
3702 for table in tables {
3706 if let Some(name) = table.get("name").and_then(|v| v.as_str()) {
3707 uris.push(format!("hyper://tables/{name}/schema"));
3708 uris.push(format!("hyper://tables/{name}/sample"));
3709 uris.push(format!("hyper://tables/{name}/csv-sample"));
3710 }
3711 }
3712 }
3713 let store = Arc::clone(&self.saved_queries);
3714 if let Ok(saved) = self.with_saved_query_store(|engine| store.list(engine)) {
3715 for q in saved {
3716 uris.push(format!("hyper://queries/{}/definition", q.name));
3717 uris.push(format!("hyper://queries/{}/result", q.name));
3718 }
3719 }
3720 uris
3721 }
3722
3723 fn build_readme_body(&self) -> Result<ResourceBody, McpError> {
3731 let status = self.with_engine(super::engine::Engine::status)?;
3732 let tables = self
3733 .with_engine(super::engine::Engine::describe_tables)
3734 .unwrap_or_default();
3735
3736 let workspace_mode = status
3737 .get("workspace_mode")
3738 .and_then(|v| v.as_str())
3739 .unwrap_or("unknown");
3740 let workspace_path = status
3741 .get("workspace_path")
3742 .and_then(|v| v.as_str())
3743 .unwrap_or("");
3744 let read_only = status
3745 .get("read_only")
3746 .and_then(serde_json::Value::as_bool)
3747 .unwrap_or(false);
3748 let table_count = tables.len();
3749
3750 let mut md = String::new();
3751 md.push_str("# HyperDB workspace\n\n");
3752 let _ = writeln!(
3753 md,
3754 "- Mode: **{workspace_mode}**{}\n",
3755 if read_only { " (read-only)" } else { "" }
3756 );
3757 if !workspace_path.is_empty() {
3758 let _ = writeln!(md, "- Path: `{workspace_path}`\n");
3759 }
3760 let _ = write!(md, "- Tables: **{table_count}**\n\n");
3761
3762 if tables.is_empty() {
3763 md.push_str(
3764 "_No tables loaded yet._ Use the `load_file` or `load_data` tools to \
3765 ingest CSV / JSON / Parquet / Arrow IPC data; call `inspect_file` \
3766 first if you're unsure of the schema.\n",
3767 );
3768 } else {
3769 md.push_str("## Tables\n\n");
3770 md.push_str("| Table | Rows | Columns |\n");
3771 md.push_str("|---|---:|---|\n");
3772 for t in &tables {
3773 let name = t.get("name").and_then(|v| v.as_str()).unwrap_or("?");
3774 let rows = t
3775 .get("row_count")
3776 .and_then(serde_json::Value::as_i64)
3777 .unwrap_or(0);
3778 let cols: Vec<String> = t
3779 .get("columns")
3780 .and_then(|v| v.as_array())
3781 .map(|arr| {
3782 arr.iter()
3783 .filter_map(|c| {
3784 let n = c.get("name")?.as_str()?;
3785 let ty = c.get("type")?.as_str()?;
3786 Some(format!("`{n}` {ty}"))
3787 })
3788 .collect()
3789 })
3790 .unwrap_or_default();
3791 let _ = writeln!(md, "| `{name}` | {rows} | {} |\n", cols.join(", "));
3792 }
3793 md.push('\n');
3794 md.push_str("## Related resources\n\n");
3795 for t in &tables {
3796 if let Some(name) = t.get("name").and_then(|v| v.as_str()) {
3797 let _ = write!(md, "- `hyper://tables/{name}/schema` — JSON schema and row count\n\
3798 - `hyper://tables/{name}/sample` — first {TABLE_SAMPLE_ROWS} rows as JSON\n\
3799 - `hyper://tables/{name}/csv-sample` — first {TABLE_CSV_SAMPLE_ROWS} rows as CSV\n");
3800 }
3801 }
3802 md.push('\n');
3803 }
3804
3805 md.push_str(
3806 "## Tool hints\n\n\
3807 - `query(sql)` — read-only SQL (SELECT / WITH / EXPLAIN / SHOW / VALUES).\n\
3808 - `execute(sql)` — DDL/DML (disabled in read-only mode).\n\
3809 - `sample(table, n)` — configurable row sample; the fixed-size\n \
3810 `hyper://tables/{name}/sample` resource uses n=5.\n\
3811 - `inspect_file(path)` — dry-run schema inference before loading.\n\
3812 - `chart(sql, chart_type, ...)` — render a PNG/SVG from a query.\n\
3813 - `export(sql|table, path, format)` — write to CSV / Parquet / Arrow IPC / .hyper.\n",
3814 );
3815
3816 Ok(ResourceBody::Text {
3817 mime_type: "text/markdown".into(),
3818 content: md,
3819 })
3820 }
3821
3822 fn build_csv_sample_body(&self, table: &str) -> Result<ResourceBody, McpError> {
3826 let sample =
3827 self.with_engine(|engine| engine.sample_table(table, TABLE_CSV_SAMPLE_ROWS))?;
3828
3829 let header: Vec<String> = sample
3833 .get("schema")
3834 .and_then(|v| v.as_array())
3835 .map(|cols| {
3836 cols.iter()
3837 .filter_map(|c| c.get("name").and_then(|n| n.as_str()).map(String::from))
3838 .collect()
3839 })
3840 .filter(|v: &Vec<String>| !v.is_empty())
3841 .or_else(|| {
3842 sample
3843 .get("rows")
3844 .and_then(|v| v.as_array())
3845 .and_then(|rows| rows.first())
3846 .and_then(|r| r.as_object())
3847 .map(|o| o.keys().cloned().collect())
3848 })
3849 .unwrap_or_default();
3850
3851 let mut wtr = csv::Writer::from_writer(Vec::<u8>::new());
3852 if !header.is_empty() {
3853 wtr.write_record(&header).map_err(|e| {
3854 McpError::new(
3855 ErrorCode::InternalError,
3856 format!("Failed to write CSV header: {e}"),
3857 )
3858 })?;
3859 }
3860 if let Some(rows) = sample.get("rows").and_then(|v| v.as_array()) {
3861 for row in rows {
3862 let record: Vec<String> = header
3863 .iter()
3864 .map(|col| row.get(col).map(value_to_csv_cell).unwrap_or_default())
3865 .collect();
3866 wtr.write_record(&record).map_err(|e| {
3867 McpError::new(
3868 ErrorCode::InternalError,
3869 format!("Failed to write CSV row: {e}"),
3870 )
3871 })?;
3872 }
3873 }
3874 let bytes = wtr.into_inner().map_err(|e| {
3875 McpError::new(
3876 ErrorCode::InternalError,
3877 format!("Failed to finalize CSV: {e}"),
3878 )
3879 })?;
3880 let content = String::from_utf8(bytes).map_err(|e| {
3881 McpError::new(
3882 ErrorCode::InternalError,
3883 format!("CSV produced invalid UTF-8: {e}"),
3884 )
3885 })?;
3886
3887 Ok(ResourceBody::Text {
3888 mime_type: "text/csv".into(),
3889 content,
3890 })
3891 }
3892
3893 fn build_analyze_context(&self, table: &str) -> String {
3896 match self.with_engine(|engine| engine.sample_table(table, 10)) {
3897 Ok(sample) => format!(
3898 "Schema and sample:\n```json\n{}\n```",
3899 serde_json::to_string_pretty(&sample).unwrap_or_else(|_| sample.to_string())
3900 ),
3901 Err(e) => format!("(Could not load table context: {e})"),
3902 }
3903 }
3904
3905 fn build_brief_context(&self, table: &str) -> String {
3907 match self.with_engine(|engine| engine.sample_table(table, 5)) {
3908 Ok(sample) => format!(
3909 "```json\n{}\n```",
3910 serde_json::to_string_pretty(&sample).unwrap_or_else(|_| sample.to_string())
3911 ),
3912 Err(e) => format!("(Could not load table context: {e})"),
3913 }
3914 }
3915}
3916
3917#[tool_handler]
3920#[prompt_handler]
3921impl ServerHandler for HyperMcpServer {
3922 fn get_info(&self) -> ServerInfo {
3923 let sql_dialect = "\n\
3924\n\
3925SQL DIALECT — Salesforce Data Cloud SQL (PostgreSQL-compatible with extensions).\n\
3926Key differences from standard PostgreSQL an LLM should know:\n\
3927\n\
3928TYPES\n\
3929- Supported: SMALLINT, INTEGER/INT, BIGINT, REAL/FLOAT4, DOUBLE PRECISION/FLOAT8,\n\
3930 NUMERIC(p,s)/DECIMAL(p,s), BOOLEAN, TEXT, CHAR(n), VARCHAR(n), BYTES,\n\
3931 DATE, TIME, TIMESTAMP, TIMESTAMPTZ, INTERVAL, and arrays of any atomic type\n\
3932- NUMERIC precision > 18 requires .hyper file format version 3 (default in this MCP)\n\
3933- No SERIAL / BIGSERIAL / UUID / JSON / JSONB / geometry types\n\
3934\n\
3935SELECT / QUERY\n\
3936- LIMIT / OFFSET work as in PostgreSQL; TOP N is also accepted\n\
3937- LATERAL is optional: subqueries in FROM always see preceding FROM items implicitly\n\
3938- DISTINCT ON (expr, ...) is supported\n\
3939- FROM clause is optional (can evaluate expressions without a table)\n\
3940- Function calls may appear directly in the FROM list\n\
3941- information_schema and pg_catalog do NOT exist; use the describe/sample tools\n\
3942\n\
3943GROUP BY / AGGREGATION\n\
3944- GROUPING SETS, ROLLUP, CUBE all supported\n\
3945- GROUP BY DISTINCT removes duplicate grouping sets before processing\n\
3946- FILTER (WHERE ...) clause supported on aggregate calls\n\
3947- Ordered-set aggregates: MODE(), PERCENTILE_CONT(), PERCENTILE_DISC() with WITHIN GROUP (ORDER BY ...)\n\
3948- APPROX_COUNT_DISTINCT() for fast approximate cardinality\n\
3949- GROUPING() function identifies which columns are aggregated in GROUPING SETS\n\
3950\n\
3951WINDOW FUNCTIONS\n\
3952- Standard: row_number, rank, dense_rank, percent_rank, cume_dist, ntile, lag, lead,\n\
3953 first_value, last_value, nth_value\n\
3954- Hyper extension: modified_rank() — like rank() but assigns the LOWEST rank on ties\n\
3955- IGNORE NULLS / RESPECT NULLS supported on last_value only\n\
3956- nth_value supports FROM FIRST / FROM LAST\n\
3957- Frame modes: ROWS, RANGE, GROUPS; EXCLUDE CURRENT ROW / GROUP / TIES / NO OTHERS\n\
3958- Window-specific functions do NOT support DISTINCT or ORDER BY in their argument list\n\
3959\n\
3960SET-RETURNING FUNCTIONS (usable in FROM)\n\
3961- unnest(array) — expands an array to rows; supports WITH ORDINALITY\n\
3962- generate_series(start, stop [, step]) — numeric and datetime variants\n\
3963- external(path, format => '...') — reads Parquet, CSV, Iceberg etc. directly from files\n\
3964\n\
3965SET OPERATORS\n\
3966- UNION, INTERSECT, EXCEPT all supported; INTERSECT binds tighter than UNION/EXCEPT\n\
3967- ORDER BY and LIMIT/OFFSET can appear on parenthesized sub-expressions or the final result\n\
3968\n\
3969CTEs\n\
3970- WITH and WITH RECURSIVE both supported\n\
3971- CTEs evaluate once per query execution even if referenced multiple times\n\
3972\n\
3973IDENTIFIERS\n\
3974- Unquoted identifiers are folded to lowercase; double-quote to preserve case or use special chars\n\
3975- Quote names containing uppercase letters, digits at the start, or special characters\n\
3976\n\
3977NOT AVAILABLE IN HYPER (Data 360 / Data Cloud-only features)\n\
3978- AI functions: AI_CLASSIFY, AI_SENTIMENT, and other Data Cloud AI scalar functions\n\
3979- Data Cloud federation / streaming-specific functions\n\
3980\n\
3981Full SQL reference: https://developer.salesforce.com/docs/data/data-cloud-query-guide/references/dc-sql-reference";
3982
3983 let header = if self.read_only {
3984 "HyperDB MCP (read-only): SQL analytics for LLM workflows. Query existing tables, \
3985 sample data, export results. Mutating operations are disabled. \
3986 Call get_readme for a concise tool index, parameter rules, and usage examples."
3987 } else {
3988 "HyperDB MCP: instant SQL analytics for LLM workflows. Load data (CSV, JSON, Parquet, \
3989 Arrow IPC, Apache Iceberg), query with SQL, export results (Parquet, Iceberg, Arrow IPC, \
3990 CSV, Hyper). Use query for SELECT and execute for DDL/DML. \
3991 Call get_readme for a concise tool index, parameter rules, and usage examples."
3992 };
3993 let instructions = format!("{header}{sql_dialect}");
3994 let mut server_info = Implementation::default();
3995 server_info.name = "HyperDB".into();
3996 server_info.title = Some("HyperDB — Hyper SQL Analytics".into());
3997 server_info.version = env!("CARGO_PKG_VERSION").into();
3998 server_info.description = Some(
3999 "MCP server for Tableau Hyper: instant SQL analytics over \
4000 CSV, JSON, Parquet, Arrow IPC, and Apache Iceberg with schema inference, \
4001 partial schema overrides, full-file numeric widening, and \
4002 dry-run file inspection. SQL dialect is PostgreSQL-compatible with \
4003 extensions (Salesforce Data Cloud SQL). Full SQL reference: \
4004 https://developer.salesforce.com/docs/data/data-cloud-query-guide/references/dc-sql-reference/data-cloud-sql-context.html"
4005 .into(),
4006 );
4007
4008 let mut info = ServerInfo::default();
4009 info.instructions = Some(instructions);
4010 info.server_info = server_info;
4011 info.capabilities = ServerCapabilities::builder()
4012 .enable_tools()
4013 .enable_prompts()
4014 .enable_resources()
4015 .enable_resources_subscribe()
4020 .enable_resources_list_changed()
4021 .build();
4022 info
4023 }
4024
4025 async fn initialize(
4026 &self,
4027 request: InitializeRequestParams,
4028 context: RequestContext<RoleServer>,
4029 ) -> Result<InitializeResult, rmcp::ErrorData> {
4030 let name = &request.client_info.name;
4031 let version = &request.client_info.version;
4032 let label = if version.is_empty() {
4033 name.clone()
4034 } else {
4035 format!("{name} {version}")
4036 };
4037 if let Ok(mut guard) = self.client_name.lock() {
4038 *guard = Some(label);
4039 }
4040 context.peer.set_peer_info(request);
4041 Ok(self.get_info())
4042 }
4043
4044 async fn subscribe(
4053 &self,
4054 request: SubscribeRequestParams,
4055 context: RequestContext<RoleServer>,
4056 ) -> Result<(), rmcp::ErrorData> {
4057 self.subscriptions.subscribe(&request.uri, context.peer);
4058 Ok(())
4059 }
4060
4061 async fn unsubscribe(
4066 &self,
4067 request: UnsubscribeRequestParams,
4068 context: RequestContext<RoleServer>,
4069 ) -> Result<(), rmcp::ErrorData> {
4070 self.subscriptions.unsubscribe(&request.uri, &context.peer);
4071 Ok(())
4072 }
4073
4074 async fn list_resources(
4080 &self,
4081 _request: Option<PaginatedRequestParams>,
4082 _context: RequestContext<RoleServer>,
4083 ) -> Result<ListResourcesResult, rmcp::ErrorData> {
4084 let mut resources = vec![
4085 RawResource {
4086 uri: "hyper://workspace".into(),
4087 name: "Workspace Info".into(),
4088 title: Some("Hyper Workspace".into()),
4089 description: Some("Workspace mode, table count, total rows, disk usage".into()),
4090 mime_type: Some("application/json".into()),
4091 size: None,
4092 icons: None,
4093 meta: None,
4094 }
4095 .no_annotation(),
4096 RawResource {
4097 uri: "hyper://tables".into(),
4098 name: "All Tables".into(),
4099 title: Some("All Tables".into()),
4100 description: Some("List of all tables with column schemas and row counts".into()),
4101 mime_type: Some("application/json".into()),
4102 size: None,
4103 icons: None,
4104 meta: None,
4105 }
4106 .no_annotation(),
4107 RawResource {
4108 uri: "hyper://readme".into(),
4109 name: "Workspace Readme".into(),
4110 title: Some("HyperDB workspace readme".into()),
4111 description: Some(
4112 "Markdown overview of the workspace: tables, row counts, related \
4113 resources, and tool hints for LLMs orienting themselves."
4114 .into(),
4115 ),
4116 mime_type: Some("text/markdown".into()),
4117 size: None,
4118 icons: None,
4119 meta: None,
4120 }
4121 .no_annotation(),
4122 ];
4123
4124 if let Ok(tables) = self.with_engine(super::engine::Engine::describe_tables) {
4125 for table in tables {
4129 if let Some(name) = table.get("name").and_then(|v| v.as_str()) {
4130 let row_count = table
4131 .get("row_count")
4132 .and_then(serde_json::Value::as_i64)
4133 .unwrap_or(0);
4134 resources.push(
4135 RawResource {
4136 uri: format!("hyper://tables/{name}/schema"),
4137 name: format!("Schema of {name}"),
4138 title: Some(format!("{name} schema")),
4139 description: Some(format!(
4140 "Column schema and row count ({row_count} rows) for table '{name}'"
4141 )),
4142 mime_type: Some("application/json".into()),
4143 size: None,
4144 icons: None,
4145 meta: None,
4146 }
4147 .no_annotation(),
4148 );
4149 resources.push(
4150 RawResource {
4151 uri: format!("hyper://tables/{name}/sample"),
4152 name: format!("Sample of {name}"),
4153 title: Some(format!("{name} sample (JSON)")),
4154 description: Some(format!(
4155 "First {TABLE_SAMPLE_ROWS} rows of '{name}' as JSON, with schema"
4156 )),
4157 mime_type: Some("application/json".into()),
4158 size: None,
4159 icons: None,
4160 meta: None,
4161 }
4162 .no_annotation(),
4163 );
4164 resources.push(
4165 RawResource {
4166 uri: format!("hyper://tables/{name}/csv-sample"),
4167 name: format!("CSV sample of {name}"),
4168 title: Some(format!("{name} sample (CSV)")),
4169 description: Some(format!(
4170 "First {TABLE_CSV_SAMPLE_ROWS} rows of '{name}' as CSV"
4171 )),
4172 mime_type: Some("text/csv".into()),
4173 size: None,
4174 icons: None,
4175 meta: None,
4176 }
4177 .no_annotation(),
4178 );
4179 }
4180 }
4181 }
4182
4183 let store = Arc::clone(&self.saved_queries);
4184 if let Ok(saved) = self.with_saved_query_store(|engine| store.list(engine)) {
4185 for q in saved {
4186 let desc = q
4187 .description
4188 .clone()
4189 .unwrap_or_else(|| format!("Saved read-only SQL query '{}'", q.name));
4190 resources.push(
4191 RawResource {
4192 uri: format!("hyper://queries/{}/definition", q.name),
4193 name: format!("Query: {}", q.name),
4194 title: Some(format!("{} (definition)", q.name)),
4195 description: Some(format!("SQL + metadata for saved query '{}'", q.name)),
4196 mime_type: Some("application/json".into()),
4197 size: None,
4198 icons: None,
4199 meta: None,
4200 }
4201 .no_annotation(),
4202 );
4203 resources.push(
4204 RawResource {
4205 uri: format!("hyper://queries/{}/result", q.name),
4206 name: format!("Result: {}", q.name),
4207 title: Some(format!("{} (result)", q.name)),
4208 description: Some(format!("{desc} — re-runs on every read")),
4209 mime_type: Some("application/json".into()),
4210 size: None,
4211 icons: None,
4212 meta: None,
4213 }
4214 .no_annotation(),
4215 );
4216 }
4217 }
4218
4219 Ok(ListResourcesResult {
4220 resources,
4221 next_cursor: None,
4222 meta: None,
4223 })
4224 }
4225
4226 async fn list_resource_templates(
4229 &self,
4230 _request: Option<PaginatedRequestParams>,
4231 _context: RequestContext<RoleServer>,
4232 ) -> Result<ListResourceTemplatesResult, rmcp::ErrorData> {
4233 let templates = vec![
4234 RawResourceTemplate {
4235 uri_template: "hyper://tables/{name}/schema".into(),
4236 name: "Table Schema".into(),
4237 title: Some("Table Schema".into()),
4238 description: Some(
4239 "Column schema, types, nullability, and row count for a named table".into(),
4240 ),
4241 mime_type: Some("application/json".into()),
4242 icons: None,
4243 }
4244 .no_annotation(),
4245 RawResourceTemplate {
4246 uri_template: "hyper://tables/{name}/sample".into(),
4247 name: "Table Sample (JSON)".into(),
4248 title: Some("Table Sample".into()),
4249 description: Some(
4250 "First few rows of a named table as JSON, with schema. For a \
4251 configurable row count use the `sample` tool instead."
4252 .into(),
4253 ),
4254 mime_type: Some("application/json".into()),
4255 icons: None,
4256 }
4257 .no_annotation(),
4258 RawResourceTemplate {
4259 uri_template: "hyper://tables/{name}/csv-sample".into(),
4260 name: "Table Sample (CSV)".into(),
4261 title: Some("Table Sample (CSV)".into()),
4262 description: Some(
4263 "First few rows of a named table as CSV, header-first, for \
4264 spreadsheet and Pandas consumers."
4265 .into(),
4266 ),
4267 mime_type: Some("text/csv".into()),
4268 icons: None,
4269 }
4270 .no_annotation(),
4271 RawResourceTemplate {
4272 uri_template: "hyper://queries/{name}/definition".into(),
4273 name: "Saved Query Definition".into(),
4274 title: Some("Saved Query Definition".into()),
4275 description: Some(
4276 "Stored SQL plus metadata (description, created_at) for a saved \
4277 query registered via the `save_query` tool."
4278 .into(),
4279 ),
4280 mime_type: Some("application/json".into()),
4281 icons: None,
4282 }
4283 .no_annotation(),
4284 RawResourceTemplate {
4285 uri_template: "hyper://queries/{name}/result".into(),
4286 name: "Saved Query Result".into(),
4287 title: Some("Saved Query Result".into()),
4288 description: Some(
4289 "Live result of a saved query. The stored SQL re-runs on every \
4290 resource read — no caching, always fresh."
4291 .into(),
4292 ),
4293 mime_type: Some("application/json".into()),
4294 icons: None,
4295 }
4296 .no_annotation(),
4297 ];
4298 Ok(ListResourceTemplatesResult {
4299 resource_templates: templates,
4300 next_cursor: None,
4301 meta: None,
4302 })
4303 }
4304
4305 async fn read_resource(
4310 &self,
4311 request: ReadResourceRequestParams,
4312 _context: RequestContext<RoleServer>,
4313 ) -> Result<ReadResourceResult, rmcp::ErrorData> {
4314 let uri = &request.uri;
4315 let (mime_type, text) = match self.resource_body_for_uri(uri) {
4316 Ok(Some(body)) => (body.mime_type().to_string(), body.to_text()),
4317 Ok(None) => {
4318 return Err(rmcp::ErrorData::invalid_params(
4319 format!("Unknown resource URI: {uri}"),
4320 None,
4321 ));
4322 }
4323 Err(e) => {
4324 let err_val = serde_json::to_value(&e).unwrap_or(Value::String(e.to_string()));
4327 let text =
4328 serde_json::to_string_pretty(&json!({ "error": err_val })).unwrap_or_default();
4329 ("application/json".into(), text)
4330 }
4331 };
4332
4333 Ok(ReadResourceResult::new(vec![
4334 ResourceContents::TextResourceContents {
4335 uri: uri.clone(),
4336 mime_type: Some(mime_type),
4337 text,
4338 meta: None,
4339 },
4340 ]))
4341 }
4342}
4343
4344fn validate_execute_batch(stmts: &[String]) -> Result<(), McpError> {
4368 use crate::engine::strip_leading_sql_comments;
4369
4370 if stmts.is_empty() {
4371 return Err(McpError::new(
4372 ErrorCode::InvalidArgument,
4373 "`sql` must be a non-empty array of SQL statements.",
4374 )
4375 .with_suggestion("Pass at least one statement: `sql: [\"INSERT INTO t VALUES (1)\"]`."));
4376 }
4377
4378 let mut has_schema_change = false;
4379 let mut has_data_mutation = false;
4380
4381 for (idx, stmt) in stmts.iter().enumerate() {
4382 if strip_leading_sql_comments(stmt).trim().is_empty() {
4383 return Err(McpError::new(
4384 ErrorCode::InvalidArgument,
4385 format!("`sql[{idx}]` is empty or contains only whitespace/comments."),
4386 )
4387 .with_suggestion("Remove the empty element or replace it with a real statement."));
4388 }
4389 match classify_statement(stmt) {
4398 StatementKind::ReadOnly => {
4399 return Err(McpError::new(
4400 ErrorCode::SqlError,
4401 format!(
4402 "`sql[{idx}]` is a read-only statement; the `execute` tool is for DDL/DML."
4403 ),
4404 )
4405 .with_suggestion(
4406 "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 …`).",
4407 ));
4408 }
4409 StatementKind::TransactionControl => {
4410 return Err(McpError::new(
4418 ErrorCode::InvalidArgument,
4419 format!(
4420 "`sql[{idx}]` is a transaction-control statement (BEGIN / COMMIT / ROLLBACK / SAVEPOINT); these are not allowed in `execute` batches."
4421 ),
4422 )
4423 .with_suggestion(
4424 "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.",
4425 ));
4426 }
4427 StatementKind::Ddl => has_schema_change = true,
4428 StatementKind::Dml => has_data_mutation = true,
4429 StatementKind::Other => {}
4430 }
4431 }
4432
4433 if has_schema_change && has_data_mutation {
4434 return Err(McpError::new(
4435 ErrorCode::InvalidArgument,
4436 "Cannot mix DDL (CREATE/DROP/ALTER/TRUNCATE/RENAME) with DML (INSERT/UPDATE/DELETE/COPY/MERGE) in one `execute` batch.",
4437 )
4438 .with_suggestion(
4439 "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.",
4440 ));
4441 }
4442
4443 if has_schema_change && stmts.len() > 1 {
4444 return Err(McpError::new(
4445 ErrorCode::InvalidArgument,
4446 "Multi-statement DDL batches are not supported.",
4447 )
4448 .with_suggestion(
4449 "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.",
4450 ));
4451 }
4452
4453 Ok(())
4454}
4455
4456fn value_to_csv_cell(v: &Value) -> String {
4462 match v {
4463 Value::Null => String::new(),
4464 Value::Bool(b) => b.to_string(),
4465 Value::Number(n) => n.to_string(),
4466 Value::String(s) => s.clone(),
4467 _ => v.to_string(),
4468 }
4469}
4470
4471fn detect_format(data: &str) -> String {
4474 let trimmed = data.trim_start();
4475 if trimmed.starts_with('[') || trimmed.starts_with('{') {
4476 "json".into()
4477 } else {
4478 "csv".into()
4479 }
4480}
4481
4482fn rand_suffix() -> String {
4486 use std::time::{SystemTime, UNIX_EPOCH};
4487 let t = SystemTime::now()
4488 .duration_since(UNIX_EPOCH)
4489 .unwrap_or_default();
4490 format!("{}", t.as_nanos() % 1_000_000_000)
4491}
4492
4493fn qualified_name(engine: &Engine, db: Option<&str>, table: &str) -> String {
4504 let alias = db.map_or_else(|| engine.primary_db_name(), str::to_string);
4505 let escaped_alias = alias.replace('"', "\"\"");
4506 let escaped_table = table.replace('"', "\"\"");
4507 format!("\"{escaped_alias}\".\"public\".\"{escaped_table}\"")
4508}
4509
4510fn target_exists(engine: &Engine, db: Option<&str>, table: &str) -> Result<bool, McpError> {
4515 let sql = format!(
4516 "SELECT 1 FROM {} LIMIT 0",
4517 qualified_name(engine, db, table)
4518 );
4519 match engine.execute_query_to_json(&sql) {
4520 Ok(_) => Ok(true),
4521 Err(e) => {
4522 let m = e.message.to_lowercase();
4523 let missing = m.contains("does not exist")
4524 || m.contains("undefined table")
4525 || e.message.contains("42P01");
4526 if missing {
4527 Ok(false)
4528 } else {
4529 Err(e)
4530 }
4531 }
4532 }
4533}
4534
4535fn count_rows(engine: &Engine, db: Option<&str>, table: &str) -> i64 {
4540 let sql = format!(
4541 "SELECT COUNT(*) AS cnt FROM {}",
4542 qualified_name(engine, db, table)
4543 );
4544 engine
4545 .execute_query_to_json(&sql)
4546 .ok()
4547 .and_then(|rows| {
4548 rows.first()
4549 .and_then(|r| r.get("cnt").and_then(serde_json::Value::as_i64))
4550 })
4551 .unwrap_or(0)
4552}
4553
4554fn probe_table_count(engine: &Engine, alias: &str) -> Value {
4558 let escaped_alias = alias.replace('"', "\"\"");
4559 let sql = format!(
4560 "SELECT COUNT(*) AS cnt FROM \"{escaped_alias}\".pg_catalog.pg_tables WHERE schemaname = 'public'"
4561 );
4562 match engine.execute_query_to_json(&sql) {
4563 Ok(rows) => rows
4564 .first()
4565 .and_then(|r| r.get("cnt").and_then(serde_json::Value::as_i64))
4566 .map_or(Value::Null, |n| json!(n)),
4567 Err(_) => Value::Null,
4568 }
4569}
4570
4571fn prepare_temp_attachments(
4575 specs: &[AttachSpec],
4576 read_only: bool,
4577) -> Result<Vec<AttachRequest>, McpError> {
4578 let mut out = Vec::with_capacity(specs.len());
4579 for spec in specs {
4580 let writable = spec.writable.unwrap_or(false);
4581 if writable && read_only {
4582 return Err(McpError::new(
4583 ErrorCode::ReadOnlyViolation,
4584 format!(
4585 "temp_attach for alias '{}' requested writable:true but the server is --read-only",
4586 spec.alias
4587 ),
4588 ));
4589 }
4590 let on_missing = attach::OnMissing::parse(spec.on_missing.as_deref())?;
4591 if on_missing == attach::OnMissing::Create && !writable {
4592 return Err(McpError::new(
4593 ErrorCode::InvalidArgument,
4594 format!(
4595 "temp_attach alias '{}' has on_missing='create' but writable is not true — \
4596 an empty .hyper file that cannot be written to cannot be populated.",
4597 spec.alias
4598 ),
4599 ));
4600 }
4601 let source = match spec.kind.as_str() {
4602 "local_file" => {
4603 let Some(raw) = spec.path.as_deref() else {
4604 return Err(McpError::new(
4605 ErrorCode::InvalidArgument,
4606 format!("temp_attach alias '{}' requires a 'path'", spec.alias),
4607 ));
4608 };
4609 let resolved = match on_missing {
4610 attach::OnMissing::Error => attach::validate_local_path(raw)?,
4611 attach::OnMissing::Create => attach::validate_local_path_for_create(raw)?,
4612 };
4613 AttachSource::LocalFile { path: resolved }
4614 }
4615 other => {
4616 return Err(McpError::new(
4617 ErrorCode::InvalidArgument,
4618 format!(
4619 "Unsupported temp_attach kind '{other}' for alias '{}'. Only 'local_file' is supported today.",
4620 spec.alias
4621 ),
4622 ));
4623 }
4624 };
4625 attach::validate_alias(&spec.alias)?;
4626 out.push(AttachRequest {
4627 alias: spec.alias.clone(),
4628 source,
4629 writable,
4630 on_missing,
4631 });
4632 }
4633 Ok(out)
4634}
4635
4636fn perform_copy(
4641 engine: &Engine,
4642 mode: &str,
4643 target_db: Option<&str>,
4644 target_table: &str,
4645 sql_body: &str,
4646) -> Result<Value, McpError> {
4647 let qualified = qualified_name(engine, target_db, target_table);
4648 let exists = target_exists(engine, target_db, target_table)?;
4649 let timer = crate::stats::StatsTimer::start();
4650
4651 match mode {
4652 "create" => {
4653 if exists {
4654 return Err(McpError::new(
4655 ErrorCode::InvalidArgument,
4656 format!(
4657 "Target '{target_table}' already exists. Use mode='append' to add rows or mode='replace' to drop and recreate."
4658 ),
4659 ));
4660 }
4661 engine.execute_command(&format!("CREATE TABLE {qualified} AS {sql_body}"))?;
4662 }
4663 "append" => {
4664 if !exists {
4665 return Err(McpError::new(
4666 ErrorCode::InvalidArgument,
4667 format!(
4668 "Target '{target_table}' does not exist. Use mode='create' to create it from the query or mode='replace' to drop and recreate."
4669 ),
4670 ));
4671 }
4672 engine.execute_command(&format!("INSERT INTO {qualified} {sql_body}"))?;
4673 }
4674 "replace" => {
4675 engine.execute_command(&format!("DROP TABLE IF EXISTS {qualified}"))?;
4684 engine.execute_command(&format!("CREATE TABLE {qualified} AS {sql_body}"))?;
4685 }
4686 other => {
4687 return Err(McpError::new(
4688 ErrorCode::InvalidArgument,
4689 format!("copy_query mode '{other}' is not supported"),
4690 ));
4691 }
4692 }
4693
4694 let elapsed_ms = timer.elapsed_ms();
4695 let row_count = count_rows(engine, target_db, target_table);
4696 Ok(json!({
4697 "target_table": target_table,
4698 "target_database": target_db.unwrap_or(LOCAL_ALIAS),
4699 "mode": mode,
4700 "row_count": row_count,
4701 "stats": { "operation": "copy_query", "elapsed_ms": elapsed_ms },
4702 }))
4703}
4704
4705#[cfg(test)]
4706mod validate_execute_batch_tests {
4707 use super::*;
4708
4709 fn s(v: &[&str]) -> Vec<String> {
4710 v.iter().map(|x| (*x).to_string()).collect()
4711 }
4712
4713 #[test]
4714 fn rejects_empty_array() {
4715 let err = validate_execute_batch(&[]).unwrap_err();
4716 assert_eq!(err.code, ErrorCode::InvalidArgument);
4717 }
4718
4719 #[test]
4720 fn rejects_whitespace_only_element() {
4721 let err = validate_execute_batch(&s(&[" "])).unwrap_err();
4722 assert_eq!(err.code, ErrorCode::InvalidArgument);
4723 assert!(err.message.contains("sql[0]"));
4724 let err = validate_execute_batch(&s(&["INSERT INTO t VALUES (1)", "/* */"])).unwrap_err();
4725 assert!(err.message.contains("sql[1]"));
4726 }
4727
4728 #[test]
4729 fn rejects_read_only_element() {
4730 let err =
4731 validate_execute_batch(&s(&["INSERT INTO t VALUES (1)", "SELECT 1"])).unwrap_err();
4732 assert_eq!(err.code, ErrorCode::SqlError);
4733 assert!(err.message.contains("sql[1]"));
4734 }
4735
4736 #[test]
4737 fn rejects_transaction_control_in_batch() {
4738 for sql in [
4741 "BEGIN",
4742 "COMMIT",
4743 "ROLLBACK",
4744 "SAVEPOINT sp1",
4745 "START TRANSACTION",
4746 "END",
4747 "RELEASE SAVEPOINT sp1",
4748 ] {
4749 let err = validate_execute_batch(&s(&["INSERT INTO t VALUES (1)", sql])).unwrap_err();
4750 assert_eq!(err.code, ErrorCode::InvalidArgument, "for `{sql}`");
4751 assert!(
4752 err.message.contains("transaction-control"),
4753 "for `{sql}`: {}",
4754 err.message
4755 );
4756 }
4757 }
4758
4759 #[test]
4760 fn rejects_transaction_control_singleton() {
4761 let err = validate_execute_batch(&s(&["BEGIN"])).unwrap_err();
4766 assert_eq!(err.code, ErrorCode::InvalidArgument);
4767 }
4768
4769 #[test]
4770 fn rejects_ddl_dml_mix() {
4771 let err =
4772 validate_execute_batch(&s(&["CREATE TABLE x (i INT)", "INSERT INTO x VALUES (1)"]))
4773 .unwrap_err();
4774 assert_eq!(err.code, ErrorCode::InvalidArgument);
4775 assert!(err.message.contains("DDL"));
4776 }
4777
4778 #[test]
4779 fn rejects_multi_ddl() {
4780 let err = validate_execute_batch(&s(&["CREATE TABLE a (i INT)", "CREATE TABLE b (j INT)"]))
4781 .unwrap_err();
4782 assert_eq!(err.code, ErrorCode::InvalidArgument);
4783 assert!(err.message.contains("Multi-statement DDL"));
4784 }
4785
4786 #[test]
4787 fn allows_single_ddl() {
4788 validate_execute_batch(&s(&["CREATE TABLE a (i INT)"])).unwrap();
4789 }
4790
4791 #[test]
4792 fn allows_multi_dml() {
4793 validate_execute_batch(&s(&[
4794 "UPDATE settings SET value = 'x' WHERE key = 'k'",
4795 "INSERT INTO settings (key, value) SELECT 'k', 'x' WHERE NOT EXISTS (SELECT 1 FROM settings WHERE key = 'k')",
4796 ]))
4797 .unwrap();
4798 }
4799
4800 #[test]
4801 fn allows_other_kinds() {
4802 validate_execute_batch(&s(&["SET schema_search_path = 'mydb'"])).unwrap();
4806 }
4807
4808 #[test]
4809 fn allows_trailing_semicolon() {
4810 validate_execute_batch(&s(&["INSERT INTO t VALUES (1);"])).unwrap();
4811 }
4812}