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 data_url: Option<String>,
773 pub database: Option<String>,
781}
782
783#[derive(Debug, Deserialize, JsonSchema)]
787pub struct AnalyzeTableArgs {
788 pub table: String,
790}
791
792#[derive(Debug, Deserialize, JsonSchema)]
794pub struct CompareTablesArgs {
795 pub table_a: String,
797 pub table_b: String,
799}
800
801#[derive(Debug, Deserialize, JsonSchema)]
803pub struct DataQualityArgs {
804 pub table: String,
806}
807
808#[derive(Debug, Deserialize, JsonSchema)]
810pub struct SuggestQueriesArgs {
811 pub table: String,
813 pub goal: Option<String>,
815}
816
817pub struct HyperMcpServer {
826 engine: Arc<Mutex<Option<Engine>>>,
827 catalog_ready: Arc<Mutex<bool>>,
833 watchers: Arc<crate::watcher::WatcherRegistry>,
834 saved_queries: Arc<dyn SavedQueryStore>,
835 subscriptions: Arc<SubscriptionRegistry>,
836 attachments: Arc<AttachRegistry>,
842 workspace_path: Option<String>,
846 read_only: bool,
847 no_daemon: bool,
849 last_heartbeat: std::sync::Mutex<std::time::Instant>,
851 client_name: std::sync::Mutex<Option<String>>,
855 #[expect(dead_code, reason = "constructed for rmcp 1.x macro-based dispatch")]
860 tool_router: ToolRouter<Self>,
861 #[expect(dead_code, reason = "constructed for rmcp 1.x macro-based dispatch")]
862 prompt_router: PromptRouter<Self>,
863}
864
865impl std::fmt::Debug for HyperMcpServer {
866 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
867 f.debug_struct("HyperMcpServer")
868 .field("persistent_path", &self.workspace_path)
869 .field("read_only", &self.read_only)
870 .field("no_daemon", &self.no_daemon)
871 .finish_non_exhaustive()
872 }
873}
874
875impl HyperMcpServer {
876 pub fn new(persistent_path: Option<String>, read_only: bool) -> Self {
896 Self::with_options(persistent_path, read_only, false)
897 }
898
899 pub fn with_no_daemon(
901 persistent_path: Option<String>,
902 read_only: bool,
903 no_daemon: bool,
904 ) -> Self {
905 Self::with_options(persistent_path, read_only, no_daemon)
906 }
907
908 fn with_options(persistent_path: Option<String>, read_only: bool, no_daemon: bool) -> Self {
909 let saved_queries: Arc<dyn SavedQueryStore> = build_store(persistent_path.as_deref());
912 Self {
913 engine: Arc::new(Mutex::new(None)),
914 catalog_ready: Arc::new(Mutex::new(false)),
915 watchers: Arc::new(crate::watcher::WatcherRegistry::new()),
916 saved_queries,
917 subscriptions: Arc::new(SubscriptionRegistry::new()),
918 attachments: Arc::new(AttachRegistry::new()),
923 workspace_path: persistent_path,
924 read_only,
925 no_daemon,
926 last_heartbeat: std::sync::Mutex::new(std::time::Instant::now()),
927 client_name: std::sync::Mutex::new(None),
928 tool_router: Self::tool_router(),
929 prompt_router: Self::prompt_router(),
930 }
931 }
932
933 #[must_use]
937 pub fn subscriptions_handle(&self) -> Arc<SubscriptionRegistry> {
938 Arc::clone(&self.subscriptions)
939 }
940
941 pub(crate) fn notify_table_changed(&self, table: &str) {
948 for uri in uris_for_table_change(table) {
949 self.subscriptions.notify_updated(&uri);
950 }
951 }
952
953 pub(crate) fn notify_workspace_changed(&self) {
958 for uri in uris_for_workspace_change() {
959 self.subscriptions.notify_updated(uri);
960 }
961 }
962
963 pub(crate) fn notify_resource_list_changed(&self) {
968 self.subscriptions.notify_list_changed();
969 }
970
971 fn client_name(&self) -> Option<String> {
974 self.client_name.lock().ok().and_then(|g| g.clone())
975 }
976
977 #[must_use]
980 pub fn engine_handle(&self) -> Arc<Mutex<Option<Engine>>> {
981 Arc::clone(&self.engine)
982 }
983
984 #[must_use]
986 pub fn watchers_handle(&self) -> Arc<crate::watcher::WatcherRegistry> {
987 Arc::clone(&self.watchers)
988 }
989
990 #[must_use]
993 pub fn attachments_handle(&self) -> Arc<AttachRegistry> {
994 Arc::clone(&self.attachments)
995 }
996
997 #[must_use]
999 pub fn is_read_only(&self) -> bool {
1000 self.read_only
1001 }
1002
1003 fn check_writable(&self, operation: &str) -> Result<(), McpError> {
1006 if self.read_only {
1007 Err(McpError::new(
1008 ErrorCode::ReadOnlyViolation,
1009 format!("Operation '{operation}' is not permitted in read-only mode"),
1010 ))
1011 } else {
1012 Ok(())
1013 }
1014 }
1015
1016 fn resolve_db(
1025 &self,
1026 engine: &Engine,
1027 database: Option<&str>,
1028 persist: Option<bool>,
1029 require_writable: bool,
1030 ) -> Result<Option<String>, McpError> {
1031 let effective = match (database, persist) {
1032 (Some(db), _) => Some(db),
1033 (None, Some(true)) => Some(Engine::PERSISTENT_ALIAS),
1034 _ => None,
1035 };
1036 let effective = effective.filter(|s| !s.eq_ignore_ascii_case(crate::attach::LOCAL_ALIAS));
1038
1039 let resolved = engine.resolve_target_db(effective)?;
1040 let primary = engine.primary_db_name();
1041
1042 if resolved == primary {
1043 return Ok(None);
1044 }
1045
1046 if require_writable && resolved != Engine::PERSISTENT_ALIAS {
1047 match self.attachments.get(&resolved) {
1048 None => {
1049 return Err(McpError::new(
1050 ErrorCode::InvalidArgument,
1051 format!(
1052 "database '{resolved}' is not attached. \
1053 Call attach_database first, or use \"persistent\"."
1054 ),
1055 ));
1056 }
1057 Some(entry) if !entry.writable => {
1058 return Err(McpError::new(
1059 ErrorCode::InvalidArgument,
1060 format!(
1061 "database '{resolved}' was attached read-only. \
1062 Re-attach with writable:true to write to it."
1063 ),
1064 ));
1065 }
1066 _ => {}
1067 }
1068 }
1069
1070 Ok(Some(resolved))
1071 }
1072
1073 fn ensure_engine(&self) -> Result<std::sync::MutexGuard<'_, Option<Engine>>, McpError> {
1082 let mut guard = self
1083 .engine
1084 .lock()
1085 .map_err(|_| McpError::new(ErrorCode::InternalError, "Lock poisoned"))?;
1086 if guard.is_none() {
1087 tracing::info!(
1088 persistent_db = self.workspace_path.as_deref().unwrap_or("<ephemeral-only>"),
1089 no_daemon = self.no_daemon,
1090 "initializing hyper engine"
1091 );
1092 let engine = if self.no_daemon {
1093 Engine::new_no_daemon(self.workspace_path.clone())?
1094 } else {
1095 Engine::new(self.workspace_path.clone())?
1096 };
1097 tracing::info!(
1098 ephemeral_path = %engine.ephemeral_path().display(),
1099 persistent_path = ?engine.persistent_path(),
1100 log_dir = %engine.log_dir().display(),
1101 "engine ready"
1102 );
1103 if let Err(e) = self.attachments.replay_all(&engine) {
1112 tracing::warn!(err = %e.message, "failed to replay attachments on new engine");
1113 }
1114 *guard = Some(engine);
1115 if let Ok(mut ready) = self.catalog_ready.lock() {
1119 *ready = false;
1120 }
1121 }
1122 Ok(guard)
1123 }
1124
1125 fn ensure_catalog_ready(&self, engine: &Engine) {
1134 if self.read_only {
1135 return;
1136 }
1137 let Ok(mut ready) = self.catalog_ready.lock() else {
1138 return;
1139 };
1140 if *ready {
1141 return;
1142 }
1143 if let Err(e) = crate::table_catalog::ensure_exists(engine) {
1144 tracing::warn!(err = %e.message, "failed to ensure _table_catalog exists");
1145 }
1146 if let Err(e) = crate::table_catalog::reconcile(engine) {
1147 tracing::warn!(err = %e.message, "failed to reconcile _table_catalog on startup");
1148 }
1149 *ready = true;
1150 }
1151
1152 fn after_ingest_catalog_update(
1165 &self,
1166 engine: &Engine,
1167 table_name: &str,
1168 load_tool: &'static str,
1169 load_params: Option<&str>,
1170 row_count: Option<i64>,
1171 target_db: Option<&str>,
1172 ) {
1173 if let Err(e) = crate::table_catalog::upsert_stub_in(
1174 engine,
1175 table_name,
1176 load_tool,
1177 load_params,
1178 row_count,
1179 true,
1180 target_db,
1181 self.client_name().as_deref(),
1182 ) {
1183 tracing::warn!(
1184 table = %table_name,
1185 target_db = ?target_db,
1186 err = %e.message,
1187 "failed to update _table_catalog after ingest"
1188 );
1189 }
1190 }
1191
1192 #[expect(
1202 clippy::unused_self,
1203 reason = "&self required for method-call dispatch; body uses only engine + target_db"
1204 )]
1205 fn after_execute_catalog_update(&self, engine: &Engine, target_db: Option<&str>) {
1206 if let Err(e) = crate::table_catalog::reconcile_in(engine, None) {
1207 tracing::warn!(
1208 err = %e.message,
1209 "failed to reconcile persistent _table_catalog after execute"
1210 );
1211 }
1212 if let Some(alias) = target_db {
1213 if !alias.eq_ignore_ascii_case(Engine::PERSISTENT_ALIAS) {
1214 if let Err(e) = crate::table_catalog::reconcile_in(engine, Some(alias)) {
1215 tracing::warn!(
1216 target_db = alias,
1217 err = %e.message,
1218 "failed to reconcile user-DB _table_catalog after execute"
1219 );
1220 }
1221 }
1222 }
1223 }
1224
1225 fn with_engine<F, R>(&self, f: F) -> Result<R, McpError>
1234 where
1235 F: FnOnce(&Engine) -> Result<R, McpError>,
1236 {
1237 let mut guard = self.ensure_engine()?;
1238 let engine = guard.as_ref().expect("ensure_engine guarantees Some");
1239 self.ensure_catalog_ready(engine);
1244 if !self.no_daemon {
1249 self.maybe_send_heartbeat(engine.daemon_health_port());
1250 }
1251 let result = f(engine);
1252 if let Err(e) = &result {
1253 tracing::debug!(code = ?e.code, message = %e.message, "tool call returned error");
1254 if e.code == ErrorCode::ConnectionLost {
1255 tracing::warn!(
1256 "connection to hyperd lost or desynchronized ({}); \
1261 dropping engine so next call reconnects",
1262 e.message
1263 );
1264 *guard = None;
1265 if let Ok(mut ready) = self.catalog_ready.lock() {
1268 *ready = false;
1269 }
1270 if !self.no_daemon {
1274 crate::daemon::health::report_hyperd_error_to_daemon();
1275 }
1276 }
1277 }
1278 result
1279 }
1280
1281 fn maybe_send_heartbeat(&self, daemon_health_port: Option<u16>) {
1289 const HEARTBEAT_INTERVAL: std::time::Duration = std::time::Duration::from_secs(60);
1290 let should_send = self
1291 .last_heartbeat
1292 .lock()
1293 .is_ok_and(|guard| guard.elapsed() >= HEARTBEAT_INTERVAL);
1294 if should_send {
1295 if let Some(port) = daemon_health_port {
1296 let _ = crate::daemon::health::send_command(port, "HEARTBEAT");
1297 if let Ok(mut guard) = self.last_heartbeat.lock() {
1298 *guard = std::time::Instant::now();
1299 }
1300 }
1301 }
1302 }
1303
1304 fn status_degraded(&self) -> Value {
1317 let (hyperd_running, engine_block) = if let Some(info) =
1322 crate::daemon::discovery::discover()
1323 {
1324 (
1325 true,
1326 json!({
1327 "mode": "daemon",
1328 "hyperd_endpoint": info.hyperd_endpoint,
1329 "daemon_health_port": info.health_port,
1330 }),
1331 )
1332 } else if self.no_daemon {
1333 (
1335 false,
1336 json!({ "mode": "local", "hyperd_endpoint": null, "daemon_health_port": null }),
1337 )
1338 } else {
1339 (
1340 false,
1341 json!({ "mode": "daemon", "hyperd_endpoint": null, "daemon_health_port": null }),
1342 )
1343 };
1344
1345 let persistent_path = self
1346 .workspace_path
1347 .as_ref()
1348 .map_or(Value::Null, |p| Value::String(p.clone()));
1349
1350 let attachments: Vec<Value> = self
1351 .attachments
1352 .list()
1353 .iter()
1354 .map(super::attach::AttachedDb::to_json)
1355 .collect();
1356
1357 json!({
1358 "engine_busy": true,
1359 "hyperd_running": hyperd_running,
1360 "persistent_path": persistent_path,
1361 "has_persistent": self.workspace_path.is_some(),
1362 "engine": engine_block,
1363 "hyper_rust_api_version": crate::version::mcp_version_string(),
1364 "watchers": self.watchers.to_json(),
1365 "read_only": self.read_only,
1366 "attachments": attachments,
1367 })
1368 }
1369
1370 fn with_saved_query_store<F, R>(&self, f: F) -> Result<R, McpError>
1380 where
1381 F: FnOnce(Option<&Engine>) -> Result<R, McpError>,
1382 {
1383 if self.workspace_path.is_some() {
1384 self.with_engine(|engine| f(Some(engine)))
1385 } else {
1386 f(None)
1387 }
1388 }
1389
1390 #[expect(
1391 clippy::unnecessary_wraps,
1392 reason = "signature retained for API symmetry / future fallibility; returning Result/Option keeps callers from breaking when the function later grows failure cases"
1393 )]
1394 fn ok_content(val: Value) -> Result<CallToolResult, rmcp::ErrorData> {
1399 let text = serde_json::to_string_pretty(&val).unwrap_or_default();
1400 let mut result = CallToolResult::structured(val);
1401 result.content = vec![Content::text(text)];
1405 Ok(result)
1406 }
1407
1408 fn fmt_sql(sql: &str) -> String {
1411 let opts = FormatOptions {
1412 indent: Indent::Spaces(2),
1413 uppercase: Some(true),
1414 lines_between_queries: 1,
1415 ..FormatOptions::default()
1416 };
1417 let formatted = sqlformat::format(sql, &SqlQueryParams::None, &opts);
1418 if formatted.trim().is_empty() {
1419 sql.to_owned()
1420 } else {
1421 formatted
1422 }
1423 }
1424
1425 #[expect(
1426 clippy::unnecessary_wraps,
1427 reason = "signature retained for API symmetry / future fallibility; returning Result/Option keeps callers from breaking when the function later grows failure cases"
1428 )]
1429 #[expect(
1430 clippy::needless_pass_by_value,
1431 reason = "call-site ergonomics: function consumes logically-owned parameters, refactoring signatures is not worth per-site churn"
1432 )]
1433 fn err_content(e: McpError) -> Result<CallToolResult, rmcp::ErrorData> {
1438 let err_val = serde_json::to_value(&e).unwrap_or(Value::String(e.to_string()));
1439 let body = json!({"error": err_val});
1440 let text = serde_json::to_string_pretty(&body).unwrap_or_default();
1441 let mut result = CallToolResult::structured_error(body);
1442 result.content = vec![Content::text(text)];
1443 Ok(result)
1444 }
1445}
1446
1447#[tool_router]
1448impl HyperMcpServer {
1449 #[tool(
1451 description = "Ingest inline data (JSON or CSV) and run a SQL query in one call. Creates a temp table, queries, discards."
1452 )]
1453 fn query_data(
1454 &self,
1455 Parameters(params): Parameters<QueryDataParams>,
1456 ) -> Result<CallToolResult, rmcp::ErrorData> {
1457 let result = self.with_engine(|engine| {
1458 let tname = params.table_name.unwrap_or_else(|| "data".into());
1459 let temp_table = format!("_tmp_{}_{}", tname, rand_suffix());
1460 let fmt = params.format.unwrap_or_else(|| detect_format(¶ms.data));
1461 let schema_override = crate::schema::normalize_schema_param(params.schema.as_ref())?;
1462 let opts = IngestOptions {
1463 table: temp_table.clone(),
1464 mode: "replace".into(),
1465 schema_override,
1466 merge_key: None,
1467 target_db: None,
1468 };
1469
1470 let ingest_result = match fmt.as_str() {
1471 "csv" => ingest_csv(engine, ¶ms.data, &opts),
1472 _ => ingest_json(engine, ¶ms.data, &opts),
1473 }?;
1474
1475 let query_sql = params.sql.replace(&tname, &temp_table);
1476 let rows = engine.execute_query_to_json(&query_sql)?;
1477 let _ = engine.execute_command(&format!("DROP TABLE IF EXISTS \"{temp_table}\""));
1478
1479 Ok(json!({
1480 "sql": Self::fmt_sql(¶ms.sql),
1481 "result": rows,
1482 "stats": ingest_result.stats.to_json(),
1483 }))
1484 });
1485
1486 match result {
1487 Ok(val) => Self::ok_content(val),
1488 Err(e) => Self::err_content(e),
1489 }
1490 }
1491
1492 #[tool(
1494 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."
1495 )]
1496 fn query_file(
1497 &self,
1498 Parameters(params): Parameters<QueryFileParams>,
1499 ) -> Result<CallToolResult, rmcp::ErrorData> {
1500 let result = self.with_engine(|engine| {
1501 crate::attach::validate_input_path(¶ms.path, "data file")?;
1502 let stem = std::path::Path::new(¶ms.path)
1503 .file_stem()
1504 .and_then(|s| s.to_str())
1505 .unwrap_or("file")
1506 .to_string();
1507 let tname = params.table_name.unwrap_or_else(|| stem.clone());
1508 let temp_table = format!("_tmp_{}_{}", tname, rand_suffix());
1509 let schema_override = crate::schema::normalize_schema_param(params.schema.as_ref())?;
1510 let opts = IngestOptions {
1511 table: temp_table.clone(),
1512 mode: "replace".into(),
1513 schema_override,
1514 merge_key: None,
1515 target_db: None,
1516 };
1517
1518 let ingest_result = if let Some(ref json_path) = params.json_extract_path {
1519 let raw = std::fs::read_to_string(¶ms.path).map_err(|e| {
1520 McpError::new(
1521 ErrorCode::FileNotFound,
1522 format!("Cannot read file '{}': {e}", params.path),
1523 )
1524 })?;
1525 let extracted = crate::ingest::extract_json_path(&raw, json_path)?;
1526 let array_text = crate::ingest::normalize_json_or_jsonl(&extracted)?;
1527 let mut result = ingest_json(engine, &array_text, &opts)?;
1528 result.stats.operation = "query_file".into();
1529 result.stats.bytes_read = std::fs::metadata(¶ms.path).map_or(0, |m| m.len());
1530 result.stats.file_format = Some("json".into());
1531 result
1532 } else {
1533 match detect_file_format(std::path::Path::new(¶ms.path)) {
1534 InferredFileFormat::Parquet => ingest_parquet_file(engine, ¶ms.path, &opts),
1535 InferredFileFormat::ArrowIpc => {
1536 ingest_arrow_ipc_file(engine, ¶ms.path, &opts)
1537 }
1538 InferredFileFormat::Json => ingest_json_file(engine, ¶ms.path, &opts),
1539 InferredFileFormat::Csv => ingest_csv_file(engine, ¶ms.path, &opts),
1540 }?
1541 };
1542
1543 let query_sql = params.sql.replace(&tname, &temp_table);
1544 let rows = engine.execute_query_to_json(&query_sql)?;
1545 let _ = engine.execute_command(&format!("DROP TABLE IF EXISTS \"{temp_table}\""));
1546
1547 Ok(json!({
1548 "sql": Self::fmt_sql(¶ms.sql),
1549 "result": rows,
1550 "stats": ingest_result.stats.to_json(),
1551 }))
1552 });
1553
1554 match result {
1555 Ok(val) => Self::ok_content(val),
1556 Err(e) => Self::err_content(e),
1557 }
1558 }
1559
1560 #[tool(
1562 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))."
1563 )]
1564 fn load_data(
1565 &self,
1566 Parameters(params): Parameters<LoadDataParams>,
1567 ) -> Result<CallToolResult, rmcp::ErrorData> {
1568 if let Err(e) = self.check_writable("load_data") {
1569 return Self::err_content(e);
1570 }
1571 let table_name = params.table.clone();
1572 let mode = params.mode.clone().unwrap_or_else(|| "replace".into());
1577 let result = self.with_engine(|engine| {
1578 let target_db =
1579 self.resolve_db(engine, params.database.as_deref(), params.persist, true)?;
1580 let fmt = params.format.unwrap_or_else(|| detect_format(¶ms.data));
1581 let schema_override = crate::schema::normalize_schema_param(params.schema.as_ref())?;
1582 let opts = IngestOptions {
1583 table: params.table.clone(),
1584 mode: mode.clone(),
1585 schema_override,
1586 merge_key: None,
1587 target_db: target_db.clone(),
1588 };
1589
1590 let ingest_result = match fmt.as_str() {
1591 "csv" => ingest_csv(engine, ¶ms.data, &opts),
1592 _ => ingest_json(engine, ¶ms.data, &opts),
1593 }?;
1594
1595 let schema_json: Vec<Value> = ingest_result
1596 .schema
1597 .iter()
1598 .map(|c| {
1599 json!({
1600 "name": c.name,
1601 "type": c.hyper_type,
1602 "nullable": c.nullable,
1603 })
1604 })
1605 .collect();
1606
1607 {
1613 let load_params = serde_json::to_string(&json!({
1614 "mode": mode,
1615 "format": fmt,
1616 "database": target_db.as_deref().unwrap_or("local"),
1617 }))
1618 .ok();
1619 self.after_ingest_catalog_update(
1620 engine,
1621 ¶ms.table,
1622 "load_data",
1623 load_params.as_deref(),
1624 i64::try_from(ingest_result.rows).ok(),
1625 target_db.as_deref(),
1626 );
1627 }
1628
1629 Ok(json!({
1630 "rows": ingest_result.rows,
1631 "schema": schema_json,
1632 "stats": ingest_result.stats.to_json(),
1633 }))
1634 });
1635
1636 match result {
1637 Ok(val) => {
1638 self.notify_table_changed(&table_name);
1639 if mode == "replace" {
1640 self.notify_resource_list_changed();
1644 }
1645 Self::ok_content(val)
1646 }
1647 Err(e) => Self::err_content(e),
1648 }
1649 }
1650
1651 #[tool(
1653 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."
1654 )]
1655 fn load_file(
1656 &self,
1657 Parameters(params): Parameters<LoadFileParams>,
1658 ) -> Result<CallToolResult, rmcp::ErrorData> {
1659 if let Err(e) = self.check_writable("load_file") {
1660 return Self::err_content(e);
1661 }
1662 let table_name = params.table.clone();
1663 let mode = params.mode.clone().unwrap_or_else(|| "replace".into());
1664 let merge_key_vec = match validate_merge_args(&mode, params.merge_key) {
1668 Ok(v) => v,
1669 Err(e) => return Self::err_content(e),
1670 };
1671 let result = self.with_engine(|engine| {
1676 let target_db =
1677 self.resolve_db(engine, params.database.as_deref(), params.persist, true)?;
1678 crate::attach::validate_input_path(¶ms.path, "data file")?;
1679 let schema_override = crate::schema::normalize_schema_param(params.schema.as_ref())?;
1680 let opts = IngestOptions {
1681 table: params.table.clone(),
1682 mode: mode.clone(),
1683 schema_override,
1684 merge_key: merge_key_vec.clone(),
1685 target_db: target_db.clone(),
1686 };
1687
1688 let ingest_result = if let Some(ref json_path) = params.json_extract_path {
1689 let raw = std::fs::read_to_string(¶ms.path).map_err(|e| {
1690 McpError::new(
1691 ErrorCode::FileNotFound,
1692 format!("Cannot read file '{}': {e}", params.path),
1693 )
1694 })?;
1695 let extracted = crate::ingest::extract_json_path(&raw, json_path)?;
1696 let array_text = crate::ingest::normalize_json_or_jsonl(&extracted)?;
1697 let mut result = ingest_json(engine, &array_text, &opts)?;
1698 result.stats.operation = "load_file".into();
1699 result.stats.bytes_read = std::fs::metadata(¶ms.path).map_or(0, |m| m.len());
1700 result.stats.file_format = Some("json".into());
1701 result
1702 } else {
1703 match detect_file_format(std::path::Path::new(¶ms.path)) {
1704 InferredFileFormat::Parquet => ingest_parquet_file(engine, ¶ms.path, &opts),
1705 InferredFileFormat::ArrowIpc => {
1706 ingest_arrow_ipc_file(engine, ¶ms.path, &opts)
1707 }
1708 InferredFileFormat::Json => ingest_json_file(engine, ¶ms.path, &opts),
1709 InferredFileFormat::Csv => ingest_csv_file(engine, ¶ms.path, &opts),
1710 }?
1711 };
1712
1713 let schema_changed = ingest_result.stats.schema_changed;
1717
1718 let schema_json: Vec<Value> = ingest_result
1719 .schema
1720 .iter()
1721 .map(|c| {
1722 json!({
1723 "name": c.name,
1724 "type": c.hyper_type,
1725 "nullable": c.nullable,
1726 })
1727 })
1728 .collect();
1729
1730 {
1732 let load_params = serde_json::to_string(&json!({
1733 "source_path": params.path,
1734 "mode": mode,
1735 "schema": params.schema,
1736 "json_extract_path": params.json_extract_path,
1737 "merge_key": merge_key_vec,
1738 "database": target_db.as_deref().unwrap_or("local"),
1739 }))
1740 .ok();
1741 self.after_ingest_catalog_update(
1742 engine,
1743 ¶ms.table,
1744 "load_file",
1745 load_params.as_deref(),
1746 i64::try_from(ingest_result.rows).ok(),
1747 target_db.as_deref(),
1748 );
1749 }
1750
1751 Ok((
1752 json!({
1753 "rows": ingest_result.rows,
1754 "schema": schema_json,
1755 "stats": ingest_result.stats.to_json(),
1756 }),
1757 schema_changed,
1758 ))
1759 });
1760
1761 match result {
1762 Ok((val, schema_changed)) => {
1763 self.notify_table_changed(&table_name);
1764 if mode == "replace" || schema_changed {
1772 self.notify_resource_list_changed();
1773 }
1774 Self::ok_content(val)
1775 }
1776 Err(e) => Self::err_content(e),
1777 }
1778 }
1779
1780 #[tool(
1784 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.**"
1785 )]
1786 fn load_files(
1787 &self,
1788 Parameters(params): Parameters<LoadFilesParams>,
1789 ) -> Result<CallToolResult, rmcp::ErrorData> {
1790 use hyperdb_api::pool::{create_pool, PoolConfig};
1791 use hyperdb_api::CreateMode;
1792
1793 if let Err(e) = self.check_writable("load_files") {
1794 return Self::err_content(e);
1795 }
1796 if params.files.is_empty() {
1797 return Self::err_content(McpError::new(
1798 ErrorCode::EmptyData,
1799 "load_files: `files` must not be empty",
1800 ));
1801 }
1802
1803 for (idx, entry) in params.files.iter().enumerate() {
1810 if let Err(mut e) = crate::attach::validate_input_path(&entry.path, "data file") {
1811 e.message = format!("entry {idx} (table '{}'): {}", entry.table, e.message);
1812 return Self::err_content(e);
1813 }
1814 let mode = entry.mode.as_deref().unwrap_or("replace");
1815 if mode == "merge" || entry.merge_key.is_some() {
1816 return Self::err_content(McpError::new(
1817 ErrorCode::InvalidArgument,
1818 format!(
1819 "load_files does not support mode=merge yet (entry {idx}, table \
1820 '{}'). Call load_file once per file when you need merge semantics.",
1821 entry.table
1822 ),
1823 ));
1824 }
1825 }
1826
1827 let (endpoint, workspace, target_db) = match self.with_engine(|engine| {
1833 let target_db =
1834 self.resolve_db(engine, params.database.as_deref(), params.persist, true)?;
1835 let endpoint = engine.hyperd_endpoint()?;
1836 let workspace = match target_db.as_deref() {
1837 None => engine.ephemeral_path().to_string_lossy().to_string(),
1838 Some(alias) if alias.eq_ignore_ascii_case(Engine::PERSISTENT_ALIAS) => engine
1839 .persistent_path()
1840 .ok_or_else(|| {
1841 McpError::new(
1842 ErrorCode::InvalidArgument,
1843 "target 'persistent' but the server is in --ephemeral-only mode",
1844 )
1845 })?
1846 .to_string_lossy()
1847 .to_string(),
1848 Some(alias) => {
1849 let entry = self.attachments.get(alias).ok_or_else(|| {
1850 McpError::new(
1851 ErrorCode::InvalidArgument,
1852 format!("database '{alias}' is not attached"),
1853 )
1854 })?;
1855 let crate::attach::AttachSource::LocalFile { path } = &entry.source;
1856 path.to_string_lossy().to_string()
1857 }
1858 };
1859 Ok((endpoint, workspace, target_db))
1860 }) {
1861 Ok(v) => v,
1862 Err(e) => return Self::err_content(e),
1863 };
1864
1865 let file_count = params.files.len();
1868 let concurrency = params
1869 .concurrency
1870 .map_or(8, |n| n as usize)
1871 .min(file_count)
1872 .clamp(1, 16);
1873
1874 let pool = match create_pool(
1875 PoolConfig::new(endpoint, workspace)
1876 .create_mode(CreateMode::DoNotCreate)
1877 .max_size(concurrency),
1878 ) {
1879 Ok(p) => Arc::new(p),
1880 Err(e) => {
1881 return Self::err_content(McpError::new(
1882 ErrorCode::InternalError,
1883 format!("Failed to build connection pool for load_files: {e}"),
1884 ))
1885 }
1886 };
1887
1888 let Ok(rt) = tokio::runtime::Handle::try_current() else {
1891 return Self::err_content(McpError::new(
1892 ErrorCode::InternalError,
1893 "load_files must run inside a tokio runtime",
1894 ));
1895 };
1896
1897 #[derive(Default)]
1900 struct EntryOutcome {
1901 table: String,
1902 ok: Option<(u64, Vec<Value>, Value)>,
1903 err: Option<(ErrorCode, String)>,
1904 replace_mode: bool,
1905 }
1906
1907 let outcomes: Vec<EntryOutcome> = tokio::task::block_in_place(|| {
1908 rt.block_on(async {
1909 let mut set = tokio::task::JoinSet::new();
1910 for (idx, entry) in params.files.into_iter().enumerate() {
1911 let pool = Arc::clone(&pool);
1912 let entry_target_db = target_db.clone();
1913 set.spawn(async move {
1914 let mode = entry.mode.clone().unwrap_or_else(|| "replace".into());
1915 let replace_mode = mode == "replace";
1916 let mut out = EntryOutcome {
1917 table: entry.table.clone(),
1918 replace_mode,
1919 ..Default::default()
1920 };
1921
1922 let schema_override =
1927 match crate::schema::normalize_schema_param(entry.schema.as_ref()) {
1928 Ok(v) => v,
1929 Err(e) => {
1930 out.err = Some((e.code, e.message));
1931 return (idx, out);
1932 }
1933 };
1934 let _ = &entry_target_db;
1943 let opts = IngestOptions {
1944 table: entry.table.clone(),
1945 mode: mode.clone(),
1946 schema_override,
1947 merge_key: None,
1948 target_db: None,
1949 };
1950
1951 let mut conn = match pool.get().await {
1954 Ok(c) => c,
1955 Err(e) => {
1956 out.err = Some((
1957 ErrorCode::InternalError,
1958 format!("Failed to check out connection: {e}"),
1959 ));
1960 return (idx, out);
1961 }
1962 };
1963
1964 let ingest_res = if let Some(ref json_path) = entry.json_extract_path {
1969 let raw = match std::fs::read_to_string(&entry.path) {
1970 Ok(s) => s,
1971 Err(e) => {
1972 out.err = Some((
1973 ErrorCode::FileNotFound,
1974 format!("Cannot read file '{}': {e}", entry.path),
1975 ));
1976 return (idx, out);
1977 }
1978 };
1979 let extracted = match crate::ingest::extract_json_path(&raw, json_path)
1980 {
1981 Ok(v) => v,
1982 Err(e) => {
1983 out.err = Some((e.code, e.message));
1984 return (idx, out);
1985 }
1986 };
1987 let array_text =
1988 match crate::ingest::normalize_json_or_jsonl(&extracted) {
1989 Ok(v) => v,
1990 Err(e) => {
1991 out.err = Some((e.code, e.message));
1992 return (idx, out);
1993 }
1994 };
1995 crate::ingest::ingest_json_async(&mut conn, &array_text, &opts)
1996 .await
1997 .map(|mut r| {
1998 r.stats.operation = "load_file".into();
1999 r.stats.bytes_read =
2000 std::fs::metadata(&entry.path).map_or(0, |m| m.len());
2001 r.stats.file_format = Some("json".into());
2002 r
2003 })
2004 } else {
2005 match detect_file_format(std::path::Path::new(&entry.path)) {
2006 InferredFileFormat::Parquet => {
2007 ingest_parquet_file_async(&mut conn, &entry.path, &opts).await
2008 }
2009 InferredFileFormat::ArrowIpc => {
2010 ingest_arrow_ipc_file_async(&mut conn, &entry.path, &opts).await
2011 }
2012 InferredFileFormat::Json => {
2013 ingest_json_file_async(&mut conn, &entry.path, &opts).await
2014 }
2015 InferredFileFormat::Csv => {
2016 ingest_csv_file_async(&mut conn, &entry.path, &opts).await
2017 }
2018 }
2019 };
2020
2021 match ingest_res {
2022 Ok(r) => {
2023 let schema_json: Vec<Value> = r
2024 .schema
2025 .iter()
2026 .map(|c| {
2027 json!({
2028 "name": c.name,
2029 "type": c.hyper_type,
2030 "nullable": c.nullable,
2031 })
2032 })
2033 .collect();
2034 out.ok = Some((r.rows, schema_json, r.stats.to_json()));
2035 }
2036 Err(e) => {
2037 out.err = Some((e.code, e.message));
2038 }
2039 }
2040
2041 (idx, out)
2042 });
2043 }
2044
2045 let mut collected: Vec<Option<EntryOutcome>> =
2048 (0..file_count).map(|_| None).collect();
2049 while let Some(joined) = set.join_next().await {
2050 match joined {
2051 Ok((idx, outcome)) => collected[idx] = Some(outcome),
2052 Err(e) => {
2053 tracing::warn!("load_files task join error: {e}");
2056 }
2057 }
2058 }
2059 collected.into_iter().flatten().collect()
2060 })
2061 });
2062
2063 let mut any_replace_succeeded = false;
2067 let mut tables_to_notify: Vec<String> = Vec::new();
2068 let results_json: Vec<Value> = outcomes
2069 .iter()
2070 .map(|o| match (&o.ok, &o.err) {
2071 (Some((rows, schema, stats)), _) => {
2072 tables_to_notify.push(o.table.clone());
2073 if o.replace_mode {
2074 any_replace_succeeded = true;
2075 }
2076 json!({
2077 "table": o.table,
2078 "rows": rows,
2079 "schema": schema,
2080 "stats": stats,
2081 })
2082 }
2083 (None, Some((code, msg))) => json!({
2084 "table": o.table,
2085 "error": {
2086 "code": format!("{:?}", code),
2087 "message": msg,
2088 }
2089 }),
2090 (None, None) => json!({
2093 "table": o.table,
2094 "error": {
2095 "code": "InternalError",
2096 "message": "load_files task produced no outcome",
2097 }
2098 }),
2099 })
2100 .collect();
2101
2102 if let Err(e) = self.with_engine(|engine| {
2106 for o in &outcomes {
2107 if let Some((rows, _, _)) = &o.ok {
2108 self.after_ingest_catalog_update(
2109 engine,
2110 &o.table,
2111 "load_file",
2112 None,
2113 i64::try_from(*rows).ok(),
2114 target_db.as_deref(),
2115 );
2116 }
2117 }
2118 Ok(())
2119 }) {
2120 tracing::warn!("load_files: catalog update batch failed: {}", e.message);
2121 }
2122
2123 for t in &tables_to_notify {
2124 self.notify_table_changed(t);
2125 }
2126 if any_replace_succeeded {
2127 self.notify_resource_list_changed();
2128 }
2129
2130 let success_count = outcomes.iter().filter(|o| o.ok.is_some()).count();
2131 let failure_count = outcomes.len() - success_count;
2132
2133 Self::ok_content(json!({
2134 "results": results_json,
2135 "summary": {
2136 "total": outcomes.len(),
2137 "succeeded": success_count,
2138 "failed": failure_count,
2139 "concurrency": concurrency,
2140 }
2141 }))
2142 }
2143
2144 #[tool(
2147 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."
2148 )]
2149 fn load_iceberg(
2150 &self,
2151 Parameters(params): Parameters<LoadIcebergParams>,
2152 ) -> Result<CallToolResult, rmcp::ErrorData> {
2153 if let Err(e) = self.check_writable("load_iceberg") {
2154 return Self::err_content(e);
2155 }
2156 let table_name = params.table.clone();
2157 let mode = params.mode.clone().unwrap_or_else(|| "replace".into());
2158 let opts = crate::lakehouse::IcebergIngestOptions {
2159 table: params.table.clone(),
2160 mode: mode.clone(),
2161 metadata_filename: params.metadata_filename.clone(),
2162 version_as_of: params.version_as_of,
2163 };
2164
2165 let result = self.with_engine(|engine| {
2166 crate::attach::validate_input_path(¶ms.path, "iceberg table")?;
2168 let ingest_result =
2169 crate::lakehouse::ingest_iceberg_table(engine, ¶ms.path, &opts)?;
2170
2171 let schema_json: Vec<Value> = ingest_result
2172 .schema
2173 .iter()
2174 .map(|c| {
2175 json!({
2176 "name": c.name,
2177 "type": c.hyper_type,
2178 "nullable": c.nullable,
2179 })
2180 })
2181 .collect();
2182
2183 let load_params = serde_json::to_string(&json!({
2184 "source_path": params.path,
2185 "mode": mode,
2186 "format": "iceberg",
2187 "metadata_filename": params.metadata_filename,
2188 "version_as_of": params.version_as_of,
2189 }))
2190 .ok();
2191 self.after_ingest_catalog_update(
2192 engine,
2193 ¶ms.table,
2194 "load_iceberg",
2195 load_params.as_deref(),
2196 i64::try_from(ingest_result.rows).ok(),
2197 None,
2198 );
2199
2200 Ok(json!({
2201 "rows": ingest_result.rows,
2202 "schema": schema_json,
2203 "stats": ingest_result.stats.to_json(),
2204 }))
2205 });
2206
2207 match result {
2208 Ok(val) => {
2209 self.notify_table_changed(&table_name);
2210 if mode == "replace" {
2211 self.notify_resource_list_changed();
2212 }
2213 Self::ok_content(val)
2214 }
2215 Err(e) => Self::err_content(e),
2216 }
2217 }
2218
2219 #[tool(
2221 description = "Run a read-only SQL query (SELECT, WITH, EXPLAIN, SHOW, VALUES) against the workspace. For DDL/DML use the execute tool."
2222 )]
2223 fn query(
2224 &self,
2225 Parameters(params): Parameters<QueryParams>,
2226 ) -> Result<CallToolResult, rmcp::ErrorData> {
2227 let result = self.with_engine(|engine| {
2228 if !is_read_only_sql(¶ms.sql) {
2229 return Err(McpError::new(
2230 ErrorCode::SqlError,
2231 "The query tool only accepts read-only SQL (SELECT, WITH, EXPLAIN, SHOW, VALUES). Use the execute tool for DDL/DML.",
2232 ));
2233 }
2234 let target_db = self.resolve_db(engine, params.database.as_deref(), None, false)?;
2237 let _search_guard = match target_db {
2238 Some(ref alias) => Some(engine.scoped_search_path(alias)?),
2239 None => None,
2240 };
2241 const MAX_QUERY_ROWS: usize = 10_000;
2245
2246 let timer = crate::stats::StatsTimer::start();
2247 let mut rows = engine.execute_query_to_json(¶ms.sql)?;
2248 let total_rows = rows.len();
2249 let truncated = total_rows > MAX_QUERY_ROWS;
2250 if truncated {
2251 rows.truncate(MAX_QUERY_ROWS);
2252 }
2253 let elapsed = timer.elapsed_ms();
2254 let stats = crate::stats::QueryStats {
2255 operation: "query".into(),
2256 rows_returned: rows.len() as u64,
2257 rows_scanned: 0,
2258 elapsed_ms: elapsed,
2259 result_size_bytes: serde_json::to_string(&rows).map_or(0, |s| s.len() as u64),
2260 tables_touched: vec![],
2261 };
2262 let payload = if truncated {
2263 json!({
2264 "result": rows,
2265 "stats": stats.to_json(),
2266 "truncated": true,
2267 "total_rows": total_rows,
2268 "rows_returned": MAX_QUERY_ROWS,
2269 "hint": format!(
2270 "Result set has {total_rows} rows; only the first {MAX_QUERY_ROWS} \
2271 are shown. Add a LIMIT clause, aggregate with GROUP BY, or use \
2272 the `export` tool to write the full result to a file."
2273 ),
2274 })
2275 } else {
2276 json!({
2277 "result": rows,
2278 "stats": stats.to_json(),
2279 })
2280 };
2281 Ok((params.sql.clone(), payload))
2282 });
2283
2284 match result {
2285 Ok((sql, val)) => {
2286 let formatted_sql = Self::fmt_sql(&sql);
2287 let json_text = serde_json::to_string_pretty(&val).unwrap_or_default();
2288 Ok(CallToolResult::success(vec![
2289 Content::text(format!("```sql\n{formatted_sql}\n```")),
2290 Content::text(json_text),
2291 ]))
2292 }
2293 Err(e) => Self::err_content(e),
2294 }
2295 }
2296
2297 #[tool(
2299 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."
2300 )]
2301 fn execute(
2302 &self,
2303 Parameters(params): Parameters<ExecuteParams>,
2304 ) -> Result<CallToolResult, rmcp::ErrorData> {
2305 if let Err(e) = self.check_writable("execute") {
2306 return Self::err_content(e);
2307 }
2308 if let Err(e) = validate_execute_batch(¶ms.sql) {
2313 return Self::err_content(e);
2314 }
2315 let any_structural = params
2316 .sql
2317 .iter()
2318 .any(|s| matches!(classify_statement(s), StatementKind::Ddl));
2319 let result = self.with_engine(|engine| {
2320 let target_db = self.resolve_db(engine, params.database.as_deref(), None, true)?;
2324 let _search_guard = match target_db {
2325 Some(ref alias) => Some(engine.scoped_search_path(alias)?),
2326 None => None,
2327 };
2328 let total_timer = crate::stats::StatsTimer::start();
2329 let (per_statement, affected_total, operation): (Vec<Value>, u64, &'static str) =
2330 if params.sql.len() == 1 {
2331 let stmt = ¶ms.sql[0];
2335 let t = crate::stats::StatsTimer::start();
2336 let affected = engine.execute_command(stmt)?;
2337 (
2338 vec![json!({
2339 "sql": Self::fmt_sql(stmt),
2340 "affected_rows": affected,
2341 "elapsed_ms": t.elapsed_ms(),
2342 })],
2343 affected,
2344 "command",
2345 )
2346 } else {
2347 let stmts = ¶ms.sql;
2348 let (results, total) = engine.execute_in_transaction(|engine| {
2349 let mut out = Vec::with_capacity(stmts.len());
2350 let mut total: u64 = 0;
2351 for (idx, stmt) in stmts.iter().enumerate() {
2352 let t = crate::stats::StatsTimer::start();
2353 let affected = engine.execute_command(stmt).map_err(|e| {
2354 let rollback_note = format!(
2359 "Failing SQL: {sql}. All previous statements in this batch were rolled back.",
2360 sql = Self::fmt_sql(stmt)
2361 );
2362 let combined = match e.suggestion.as_deref() {
2363 Some(orig) => format!("{orig} | {rollback_note}"),
2364 None => rollback_note,
2365 };
2366 McpError::new(
2367 e.code,
2368 format!(
2369 "statement {} of {} failed: {}",
2370 idx + 1,
2371 stmts.len(),
2372 e.message
2373 ),
2374 )
2375 .with_suggestion(combined)
2376 })?;
2377 total = total.saturating_add(affected);
2381 out.push(json!({
2382 "sql": Self::fmt_sql(stmt),
2383 "affected_rows": affected,
2384 "elapsed_ms": t.elapsed_ms(),
2385 }));
2386 }
2387 Ok((out, total))
2388 })?;
2389 (results, total, "transaction")
2390 };
2391 let elapsed = total_timer.elapsed_ms();
2392 if any_structural {
2405 self.after_execute_catalog_update(engine, target_db.as_deref());
2406 }
2407 Ok(json!({
2408 "statements": per_statement.len(),
2409 "affected_rows": affected_total,
2410 "per_statement": per_statement,
2411 "stats": { "operation": operation, "elapsed_ms": elapsed },
2412 }))
2413 });
2414
2415 match result {
2416 Ok(val) => {
2417 self.notify_workspace_changed();
2422 if any_structural {
2423 self.notify_resource_list_changed();
2424 }
2425 Self::ok_content(val)
2426 }
2427 Err(e) => Self::err_content(e),
2428 }
2429 }
2430
2431 #[tool(
2433 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."
2434 )]
2435 fn sample(
2436 &self,
2437 Parameters(params): Parameters<SampleParams>,
2438 ) -> Result<CallToolResult, rmcp::ErrorData> {
2439 let result = self.with_engine(|engine| {
2440 let target_db = self.resolve_db(engine, params.database.as_deref(), None, false)?;
2441 let timer = crate::stats::StatsTimer::start();
2442 let n = params.n.unwrap_or(5);
2443 let mut sample = engine.sample_table_in(target_db.as_deref(), ¶ms.table, n)?;
2444 let elapsed = timer.elapsed_ms();
2445 if let Some(obj) = sample.as_object_mut() {
2446 obj.insert(
2447 "stats".into(),
2448 json!({ "operation": "sample", "elapsed_ms": elapsed }),
2449 );
2450 }
2451 Ok(sample)
2452 });
2453
2454 match result {
2455 Ok(val) => Self::ok_content(val),
2456 Err(e) => Self::err_content(e),
2457 }
2458 }
2459
2460 #[tool(
2462 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."
2463 )]
2464 fn chart(
2465 &self,
2466 Parameters(params): Parameters<ChartParams>,
2467 ) -> Result<CallToolResult, rmcp::ErrorData> {
2468 let result = self.with_engine(|engine| {
2469 if !is_read_only_sql(¶ms.sql) {
2470 return Err(McpError::new(
2471 ErrorCode::SqlError,
2472 "The chart tool only accepts read-only SQL (SELECT, WITH, EXPLAIN, SHOW, VALUES).",
2473 ));
2474 }
2475
2476 if let Some(out) = params.output_path.as_deref() {
2479 crate::attach::validate_output_path(out, "chart output")?;
2480 }
2481 let format = crate::chart::resolve_chart_format(
2484 params.format.as_deref(),
2485 params.output_path.as_deref(),
2486 )?;
2487
2488 let target_db = self.resolve_db(engine, params.database.as_deref(), None, false)?;
2491 let _search_guard = match target_db {
2492 Some(ref alias) => Some(engine.scoped_search_path(alias)?),
2493 None => None,
2494 };
2495
2496 let timer = crate::stats::StatsTimer::start();
2497 let rows = engine.execute_query_to_json(¶ms.sql)?;
2498
2499 let color_map = params
2502 .color_map
2503 .as_ref()
2504 .map(|m| {
2505 m.iter()
2506 .filter_map(|(k, v)| {
2507 crate::chart::parse_hex_color(v)
2508 .map(|c| (k.clone(), c))
2509 })
2510 .collect::<std::collections::HashMap<_, _>>()
2511 })
2512 .unwrap_or_default();
2513
2514 let opts = ChartOptions {
2515 chart_type: ChartType::parse(¶ms.chart_type)?,
2516 x_column: params.x.clone(),
2517 y_column: params.y.clone(),
2518 series_column: params.series.clone(),
2519 title: params.title.clone(),
2520 format,
2521 width: params.width.unwrap_or(800).clamp(200, 4096),
2522 height: params.height.unwrap_or(480).clamp(150, 4096),
2523 bins: params.bins.unwrap_or(20).clamp(1, 500),
2524 x_as_category: params.x_as_category,
2525 x_range: params.x_range,
2526 y_range: params.y_range,
2527 color_map,
2528 label_points: params.label_points.unwrap_or(false),
2529 };
2530
2531 let chart = render_chart(&rows, &opts)?;
2532
2533 let disposition = crate::chart::resolve_chart_disposition(
2537 params.inline.unwrap_or(true),
2538 params.output_path.as_deref(),
2539 opts.format,
2540 );
2541 let overwrite = params.overwrite.unwrap_or(true);
2542 if let Some(path) = disposition.path() {
2543 crate::chart::write_chart_to_disk(path, &chart.bytes, overwrite)?;
2544 }
2545
2546 let elapsed = timer.elapsed_ms();
2547 Ok((chart, elapsed, opts, disposition))
2548 });
2549
2550 match result {
2551 Ok((chart, elapsed_ms, opts, disposition)) => {
2552 let format_str = match opts.format {
2553 ChartFormat::Png => "png",
2554 ChartFormat::Svg => "svg",
2555 };
2556 let wants_inline = disposition.wants_inline();
2557 let output_path_str = disposition.path().map(|p| p.to_string_lossy().into_owned());
2558
2559 let mut stats = serde_json::Map::new();
2560 stats.insert("operation".into(), json!("chart"));
2561 stats.insert("rows_plotted".into(), json!(chart.rows_plotted));
2562 stats.insert("elapsed_ms".into(), json!(elapsed_ms));
2563 stats.insert("format".into(), json!(format_str));
2564 stats.insert("bytes".into(), json!(chart.bytes.len()));
2565 stats.insert("width".into(), json!(opts.width));
2566 stats.insert("height".into(), json!(opts.height));
2567 stats.insert("inline".into(), json!(wants_inline));
2568 if let Some(p) = output_path_str {
2569 stats.insert("output_path".into(), json!(p));
2570 }
2571 let stats_text =
2572 serde_json::to_string_pretty(&Value::Object(stats)).unwrap_or_default();
2573
2574 let mut content = Vec::with_capacity(2);
2575 if wants_inline {
2576 let b64 = base64::engine::general_purpose::STANDARD.encode(&chart.bytes);
2577 content.push(Content::image(b64, chart.mime_type.to_string()));
2578 }
2579 content.push(Content::text(stats_text));
2580 Ok(CallToolResult::success(content))
2581 }
2582 Err(e) => Self::err_content(e),
2583 }
2584 }
2585
2586 #[tool(
2589 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."
2590 )]
2591 fn watch_directory(
2592 &self,
2593 Parameters(params): Parameters<WatchDirectoryParams>,
2594 ) -> Result<CallToolResult, rmcp::ErrorData> {
2595 if let Err(e) = self.check_writable("watch_directory") {
2596 return Self::err_content(e);
2597 }
2598 let canonical = match crate::attach::validate_input_path(¶ms.path, "watch directory") {
2599 Ok(p) => p,
2600 Err(e) => return Self::err_content(e),
2601 };
2602 match self.ensure_engine() {
2605 Ok(guard) => drop(guard),
2606 Err(e) => return Self::err_content(e),
2607 }
2608
2609 let target_db = match self.with_engine(|engine| {
2613 self.resolve_db(engine, params.database.as_deref(), params.persist, true)
2614 }) {
2615 Ok(v) => v,
2616 Err(e) => return Self::err_content(e),
2617 };
2618
2619 let path = canonical;
2620 let engine_handle = self.engine_handle();
2621 let attachments = self.attachments_handle();
2622 let registry = self.watchers_handle();
2623 let options = crate::watcher::WatchOptions {
2624 max_concurrent: params.max_concurrent.unwrap_or(0) as usize,
2625 };
2626 let result = crate::watcher::start_watching(
2627 engine_handle,
2628 attachments,
2629 registry,
2630 Some(self.subscriptions_handle()),
2631 path.clone(),
2632 params.table.clone(),
2633 target_db,
2634 options,
2635 );
2636 match result {
2637 Ok(stats) => {
2638 let body = json!({
2639 "directory": path.to_string_lossy(),
2640 "table": params.table,
2641 "status": "watching",
2642 "max_concurrent": stats.max_concurrent,
2643 "initial_sweep": {
2644 "files_ingested": stats.files_ingested,
2645 "files_failed": stats.files_failed,
2646 },
2647 });
2648 Self::ok_content(body)
2649 }
2650 Err(e) => Self::err_content(e),
2651 }
2652 }
2653
2654 #[tool(
2656 description = "Stop watching a directory previously registered with watch_directory. Pending .ready files are left in place."
2657 )]
2658 fn unwatch_directory(
2659 &self,
2660 Parameters(params): Parameters<UnwatchDirectoryParams>,
2661 ) -> Result<CallToolResult, rmcp::ErrorData> {
2662 let path = std::path::PathBuf::from(¶ms.path);
2663 let result = crate::watcher::stop_watching(&self.watchers_handle(), &path);
2664 match result {
2665 Ok(summary) => Self::ok_content(summary),
2666 Err(e) => Self::err_content(e),
2667 }
2668 }
2669
2670 #[tool(
2673 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."
2674 )]
2675 fn describe(
2676 &self,
2677 Parameters(params): Parameters<DescribeParams>,
2678 ) -> Result<CallToolResult, rmcp::ErrorData> {
2679 let result = self.with_engine(|engine| {
2680 let target_db = self.resolve_db(engine, params.database.as_deref(), None, false)?;
2681 match params.table.as_deref() {
2682 Some(name) => engine
2683 .describe_table_in(target_db.as_deref(), name)
2684 .map(|t| vec![t]),
2685 None => engine.describe_tables_in(target_db.as_deref()),
2686 }
2687 });
2688
2689 match result {
2690 Ok(tables) => Self::ok_content(json!({"tables": tables})),
2691 Err(e) => Self::err_content(e),
2692 }
2693 }
2694
2695 #[tool(
2700 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)."
2701 )]
2702 #[expect(
2703 clippy::unused_self,
2704 reason = "method retained on the type for API symmetry; implementation currently does not need state"
2705 )]
2706 fn inspect_file(
2707 &self,
2708 Parameters(params): Parameters<InspectFileParams>,
2709 ) -> Result<CallToolResult, rmcp::ErrorData> {
2710 if let Err(e) = crate::attach::validate_input_path(¶ms.path, "data file") {
2711 return Self::err_content(e);
2712 }
2713 let sample_rows = params.sample_rows.unwrap_or(5).clamp(1, 50) as usize;
2714 let result = if let Some(ref json_path) = params.json_extract_path {
2715 (|| -> Result<_, McpError> {
2716 let raw = std::fs::read_to_string(¶ms.path).map_err(|e| {
2717 McpError::new(
2718 ErrorCode::FileNotFound,
2719 format!("Cannot read file '{}': {e}", params.path),
2720 )
2721 })?;
2722 let file_size = std::fs::metadata(¶ms.path).map_or(0, |m| m.len());
2723 let extracted = crate::ingest::extract_json_path(&raw, json_path)?;
2724 crate::inspect::inspect_json_from_text(&extracted, file_size, sample_rows)
2725 })()
2726 } else {
2727 crate::inspect::inspect_source(¶ms.path, sample_rows)
2728 };
2729 match result {
2730 Ok(report) => Self::ok_content(report.to_json()),
2731 Err(e) => Self::err_content(e),
2732 }
2733 }
2734
2735 #[tool(
2738 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)."
2739 )]
2740 fn export(
2741 &self,
2742 Parameters(params): Parameters<ExportParams>,
2743 ) -> Result<CallToolResult, rmcp::ErrorData> {
2744 let result = self.with_engine(|engine| {
2745 crate::attach::validate_output_path(¶ms.path, "export")?;
2748 let format_options = match params.format_options.clone() {
2752 None => None,
2753 Some(Value::Object(m)) => Some(m),
2754 Some(other) => {
2755 return Err(McpError::new(
2756 ErrorCode::SchemaMismatch,
2757 format!("export: format_options must be a JSON object, got: {other}"),
2758 ));
2759 }
2760 };
2761 let target_db = self.resolve_db(engine, params.database.as_deref(), None, false)?;
2772 let (effective_sql, effective_table) = match (¶ms.sql, ¶ms.table, &target_db) {
2773 (None, Some(t), Some(db)) => {
2774 let esc_db = db.replace('"', "\"\"");
2775 let esc_tbl = t.replace('"', "\"\"");
2776 (
2777 Some(format!(
2778 "SELECT * FROM \"{esc_db}\".\"public\".\"{esc_tbl}\""
2779 )),
2780 None,
2781 )
2782 }
2783 _ => (params.sql.clone(), params.table.clone()),
2784 };
2785 let _search_guard = match (&effective_sql, &target_db, ¶ms.sql) {
2786 (Some(_), Some(alias), Some(_)) => Some(engine.scoped_search_path(alias)?),
2789 _ => None,
2790 };
2791 let opts = ExportOptions {
2792 sql: effective_sql,
2793 table: effective_table,
2794 path: params.path,
2795 format: params.format,
2796 overwrite: params.overwrite.unwrap_or(true),
2797 format_options,
2798 source_db: target_db.clone(),
2799 };
2800 let export_result = export_to_file(engine, &opts)?;
2801 Ok(json!({
2802 "output_path": export_result.stats.output_path,
2803 "rows": export_result.rows,
2804 "file_size_bytes": export_result.stats.file_size_bytes,
2805 "stats": export_result.stats.to_json(),
2806 }))
2807 });
2808
2809 match result {
2810 Ok(val) => Self::ok_content(val),
2811 Err(e) => Self::err_content(e),
2812 }
2813 }
2814
2815 #[tool(
2819 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."
2820 )]
2821 fn save_query(
2822 &self,
2823 Parameters(params): Parameters<SaveQueryParams>,
2824 ) -> Result<CallToolResult, rmcp::ErrorData> {
2825 if let Err(e) = self.check_writable("save_query") {
2826 return Self::err_content(e);
2827 }
2828 if !is_read_only_sql(¶ms.sql) {
2833 return Self::err_content(McpError::new(
2834 ErrorCode::SqlError,
2835 "save_query only accepts read-only SQL (SELECT / WITH / EXPLAIN / SHOW / VALUES). \
2836 Use the execute tool for DDL/DML, not save_query.",
2837 ));
2838 }
2839 if params.name.is_empty() {
2840 return Self::err_content(McpError::new(
2841 ErrorCode::SchemaMismatch,
2842 "Saved query name must not be empty.",
2843 ));
2844 }
2845 let query = SavedQuery {
2846 name: params.name.clone(),
2847 sql: params.sql,
2848 description: params.description,
2849 created_at: chrono::Utc::now(),
2850 };
2851 let store = Arc::clone(&self.saved_queries);
2852 let result = self.with_saved_query_store(|engine| store.save(engine, query.clone()));
2853 match result {
2854 Ok(()) => {
2855 self.notify_resource_list_changed();
2859 Self::ok_content(json!({
2860 "saved": true,
2861 "name": query.name,
2862 "resources": [
2863 format!("hyper://queries/{}/definition", query.name),
2864 format!("hyper://queries/{}/result", query.name),
2865 ],
2866 "created_at": query.created_at.to_rfc3339(),
2867 }))
2868 }
2869 Err(e) => Self::err_content(e),
2870 }
2871 }
2872
2873 #[tool(
2875 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)."
2876 )]
2877 fn delete_query(
2878 &self,
2879 Parameters(params): Parameters<DeleteQueryParams>,
2880 ) -> Result<CallToolResult, rmcp::ErrorData> {
2881 if let Err(e) = self.check_writable("delete_query") {
2882 return Self::err_content(e);
2883 }
2884 let store = Arc::clone(&self.saved_queries);
2885 let name = params.name.clone();
2886 let result = self.with_saved_query_store(|engine| store.delete(engine, &name));
2887 match result {
2888 Ok(deleted) => {
2889 if deleted {
2890 self.notify_resource_list_changed();
2895 self.subscriptions
2896 .notify_updated(&format!("hyper://queries/{name}/definition"));
2897 self.subscriptions
2898 .notify_updated(&format!("hyper://queries/{name}/result"));
2899 }
2900 Self::ok_content(json!({
2901 "deleted": deleted,
2902 "name": params.name,
2903 }))
2904 }
2905 Err(e) => Self::err_content(e),
2906 }
2907 }
2908
2909 #[tool(
2911 description = "Update prose metadata for a table in the `_table_catalog`: source_url, source_description, purpose, license, notes, data_url. Fields you omit stay unchanged; pass an explicit empty string (\"\") to clear a field. `data_url` is the machine-actionable download URL for the raw data file (distinct from `source_url`, which is a human-readable reference page). Mechanical fields (load_tool, load_params, loaded_at, last_refreshed_at, row_count) are managed by the server. Requires an existing catalog entry — load the table first (load_file / load_data / execute CREATE TABLE) so the stub row is created automatically. Use `database` to target the metadata for a table in a non-primary writable database; read-only attachments are rejected with a clear re-attach-with-writable message. Disabled in read-only mode."
2912 )]
2913 fn set_table_metadata(
2914 &self,
2915 Parameters(params): Parameters<SetTableMetadataParams>,
2916 ) -> Result<CallToolResult, rmcp::ErrorData> {
2917 if let Err(e) = self.check_writable("set_table_metadata") {
2918 return Self::err_content(e);
2919 }
2920 let fields = crate::table_catalog::MetadataFields {
2921 source_url: params.source_url,
2922 source_description: params.source_description,
2923 purpose: params.purpose,
2924 license: params.license,
2925 notes: params.notes,
2926 data_url: params.data_url,
2927 };
2928 let table_name = params.table.clone();
2929 let result = self.with_engine(|engine| {
2930 let target_db = self.resolve_db(engine, params.database.as_deref(), None, true)?;
2936 crate::table_catalog::set_metadata_in(
2937 engine,
2938 &table_name,
2939 &fields,
2940 target_db.as_deref(),
2941 )
2942 });
2943 match result {
2944 Ok(entry) => Self::ok_content(entry.to_json()),
2945 Err(e) => Self::err_content(e),
2946 }
2947 }
2948
2949 #[tool(
2953 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."
2954 )]
2955 fn status(&self) -> Result<CallToolResult, rmcp::ErrorData> {
2956 let Ok(guard) = self.engine.try_lock() else {
2961 return Self::ok_content(self.status_degraded());
2965 };
2966 let Some(engine) = guard.as_ref() else {
2967 return Self::ok_content(self.status_degraded());
2972 };
2973 self.ensure_catalog_ready(engine);
2974 let result = engine.status();
2975
2976 match result {
2977 Ok(mut val) => {
2978 if let Some(obj) = val.as_object_mut() {
2979 obj.insert("engine_busy".into(), json!(false));
2980 obj.insert("watchers".into(), self.watchers.to_json());
2981 obj.insert("read_only".into(), json!(self.read_only));
2982 let attachments: Vec<Value> = self
2983 .attachments
2984 .list()
2985 .iter()
2986 .map(super::attach::AttachedDb::to_json)
2987 .collect();
2988 obj.insert("attachments".into(), Value::Array(attachments));
2989 }
2990 Self::ok_content(val)
2991 }
2992 Err(e) => Self::err_content(e),
2993 }
2994 }
2995
2996 #[tool(
3001 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."
3002 )]
3003 #[expect(
3004 clippy::unused_self,
3005 reason = "the #[tool] macro dispatches on &self; signature must match the rest of the tool surface even though this tool is stateless"
3006 )]
3007 #[expect(
3008 clippy::unnecessary_wraps,
3009 reason = "uniform Result<CallToolResult, rmcp::ErrorData> across all tools so the #[tool_router] dispatcher has one signature shape"
3010 )]
3011 fn get_readme(&self) -> Result<CallToolResult, rmcp::ErrorData> {
3012 Ok(CallToolResult::success(vec![Content::text(
3013 crate::readme::README,
3014 )]))
3015 }
3016
3017 #[tool(
3020 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."
3021 )]
3022 fn attach_database(
3023 &self,
3024 Parameters(params): Parameters<AttachDatabaseParams>,
3025 ) -> Result<CallToolResult, rmcp::ErrorData> {
3026 let writable = params.writable.unwrap_or(false);
3027 if writable {
3028 if let Err(e) = self.check_writable("attach_database(writable)") {
3029 return Self::err_content(e);
3030 }
3031 }
3032 let on_missing = match attach::OnMissing::parse(params.on_missing.as_deref()) {
3033 Ok(v) => v,
3034 Err(e) => return Self::err_content(e),
3035 };
3036 if on_missing == attach::OnMissing::Create && !writable {
3037 return Self::err_content(McpError::new(
3038 ErrorCode::InvalidArgument,
3039 "on_missing='create' requires writable:true — an empty .hyper file that cannot be written to cannot be populated.",
3040 ));
3041 }
3042 let source = match params.kind.as_str() {
3043 "local_file" => {
3044 let Some(raw) = params.path.as_deref() else {
3045 return Self::err_content(McpError::new(
3046 ErrorCode::InvalidArgument,
3047 "kind='local_file' requires a 'path' argument",
3048 ));
3049 };
3050 let resolved = match on_missing {
3051 attach::OnMissing::Error => attach::validate_local_path(raw),
3052 attach::OnMissing::Create => attach::validate_local_path_for_create(raw),
3053 };
3054 match resolved {
3055 Ok(canonical) => AttachSource::LocalFile { path: canonical },
3056 Err(e) => return Self::err_content(e),
3057 }
3058 }
3059 other => {
3060 return Self::err_content(McpError::new(
3061 ErrorCode::InvalidArgument,
3062 format!(
3063 "Unsupported attach kind '{other}'. Only 'local_file' is supported today; \
3064 'tcp' (remote hyperd) and 'grpc' (Data 360) are planned."
3065 ),
3066 ));
3067 }
3068 };
3069 let req = AttachRequest {
3070 alias: params.alias.clone(),
3071 source,
3072 writable,
3073 on_missing,
3074 };
3075 let registry = self.attachments_handle();
3076 let alias_for_probe = req.alias.clone();
3077 let result = self.with_engine(|engine| {
3078 let entry = registry.attach(engine, req.clone())?;
3079 let tables_visible = probe_table_count(engine, &alias_for_probe);
3084 Ok(json!({
3085 "alias": entry.alias,
3086 "kind": entry.source.kind_str(),
3087 "source": entry.source.to_json(),
3088 "writable": entry.writable,
3089 "tables_visible": tables_visible,
3090 }))
3091 });
3092 match result {
3093 Ok(val) => Self::ok_content(val),
3094 Err(e) => Self::err_content(e),
3095 }
3096 }
3097
3098 #[tool(
3100 description = "Detach a database previously registered with attach_database. No-op when the alias is unknown. Returns {detached: true/false}."
3101 )]
3102 fn detach_database(
3103 &self,
3104 Parameters(params): Parameters<DetachDatabaseParams>,
3105 ) -> Result<CallToolResult, rmcp::ErrorData> {
3106 let alias = params.alias.to_ascii_lowercase();
3111 if let Ok(watchers) = self.watchers.watchers.lock() {
3117 let conflict = watchers
3118 .values()
3119 .find(|h| h.target_db.as_deref() == Some(alias.as_str()));
3120 if let Some(h) = conflict {
3121 return Self::err_content(McpError::new(
3122 ErrorCode::InvalidArgument,
3123 format!(
3124 "cannot detach '{alias}': an active watcher on directory '{}' targets it. \
3125 Call unwatch_directory(\"{}\") first.",
3126 h.directory.display(),
3127 h.directory.display()
3128 ),
3129 ));
3130 }
3131 }
3132 let registry = self.attachments_handle();
3133 let result = self.with_engine(|engine| {
3134 let outcome = registry.detach(engine, &alias)?;
3135 if outcome {
3136 engine.clear_catalog_cache_for(&alias);
3140 }
3141 Ok(outcome)
3142 });
3143 match result {
3144 Ok(detached) => {
3145 Self::ok_content(json!({ "alias": params.alias, "detached": detached }))
3146 }
3147 Err(e) => Self::err_content(e),
3148 }
3149 }
3150
3151 #[tool(
3161 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."
3162 )]
3163 fn list_attached_databases(&self) -> Result<CallToolResult, rmcp::ErrorData> {
3164 let result = self.with_engine(|engine| {
3165 let entries = self.attachments.list();
3166 let attachments: Vec<Value> = entries
3167 .iter()
3168 .map(|entry| {
3169 let mut obj = entry.to_json();
3170 let tables_visible = probe_table_count(engine, &entry.alias);
3171 if let Some(map) = obj.as_object_mut() {
3172 map.insert("tables_visible".into(), json!(tables_visible));
3173 }
3174 obj
3175 })
3176 .collect();
3177 Ok(json!({ "attachments": attachments }))
3178 });
3179 match result {
3180 Ok(val) => Self::ok_content(val),
3181 Err(e) => Self::err_content(e),
3182 }
3183 }
3184
3185 #[tool(
3190 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."
3191 )]
3192 fn copy_query(
3193 &self,
3194 Parameters(params): Parameters<CopyQueryParams>,
3195 ) -> Result<CallToolResult, rmcp::ErrorData> {
3196 if let Err(e) = self.check_writable("copy_query") {
3197 return Self::err_content(e);
3198 }
3199 let mode = match params.mode.as_str() {
3200 "create" | "append" | "replace" => params.mode.clone(),
3201 other => {
3202 return Self::err_content(McpError::new(
3203 ErrorCode::InvalidArgument,
3204 format!(
3205 "copy_query mode '{other}' is not supported. Use 'create', 'append', or 'replace'."
3206 ),
3207 ));
3208 }
3209 };
3210 if !is_read_only_sql(¶ms.sql) {
3211 return Self::err_content(McpError::new(
3212 ErrorCode::SqlError,
3213 "copy_query's `sql` must be a read-only statement (SELECT / WITH / VALUES). \
3214 Use the execute tool for raw DDL/DML.",
3215 ));
3216 }
3217 let target_db_owned = params
3229 .target_database
3230 .as_deref()
3231 .filter(|s| !s.eq_ignore_ascii_case(LOCAL_ALIAS))
3232 .map(str::to_ascii_lowercase);
3233 let target_db = target_db_owned.as_deref();
3234 if let Some(alias) = target_db {
3235 match self.attachments.get(alias) {
3236 None => {
3237 return Self::err_content(McpError::new(
3238 ErrorCode::InvalidArgument,
3239 format!(
3240 "target_database '{alias}' is not attached. Call attach_database first."
3241 ),
3242 ));
3243 }
3244 Some(entry) if !entry.writable => {
3245 return Self::err_content(McpError::new(
3246 ErrorCode::InvalidArgument,
3247 format!(
3248 "target_database '{alias}' was attached read-only. Re-attach with writable:true to use it as a copy target."
3249 ),
3250 ));
3251 }
3252 Some(_) => {}
3253 }
3254 }
3255
3256 let temp_specs = params.temp_attach.clone().unwrap_or_default();
3259 let prepared_temps = match prepare_temp_attachments(&temp_specs, self.is_read_only()) {
3260 Ok(v) => v,
3261 Err(e) => return Self::err_content(e),
3262 };
3263
3264 let target_table = params.target_table.clone();
3265 let sql_body = params.sql.clone();
3266 let load_params = serde_json::to_string(&json!({
3267 "mode": mode,
3268 "target_database": params.target_database,
3269 "target_table": target_table,
3270 "sql": Self::fmt_sql(&sql_body),
3271 }))
3272 .ok();
3273
3274 let registry = self.attachments_handle();
3275 let result = self.with_engine(|engine| {
3276 let mut temp_aliases: Vec<String> = Vec::new();
3278 for req in &prepared_temps {
3279 match registry.attach(engine, req.clone()) {
3280 Ok(entry) => temp_aliases.push(entry.alias),
3281 Err(e) => {
3282 for alias in &temp_aliases {
3284 let _ = registry.detach(engine, alias);
3285 }
3286 return Err(e);
3287 }
3288 }
3289 }
3290
3291 let copy_outcome = perform_copy(engine, &mode, target_db, &target_table, &sql_body);
3294
3295 for alias in &temp_aliases {
3299 if let Err(e) = registry.detach(engine, alias) {
3300 tracing::warn!(
3301 alias = %alias,
3302 err = %e.message,
3303 "failed to detach temp attachment after copy_query",
3304 );
3305 }
3306 }
3307
3308 if copy_outcome.is_ok() && target_db.is_none() {
3319 let row_count = copy_outcome
3320 .as_ref()
3321 .ok()
3322 .and_then(|v| v.get("row_count").and_then(serde_json::Value::as_i64));
3323 self.after_ingest_catalog_update(
3324 engine,
3325 &target_table,
3326 "copy_query",
3327 load_params.as_deref(),
3328 row_count,
3329 target_db,
3330 );
3331 }
3332
3333 copy_outcome
3334 });
3335
3336 match result {
3337 Ok(outcome) => {
3338 if target_db.is_none() {
3340 self.notify_table_changed(&target_table);
3341 }
3342 self.notify_workspace_changed();
3343 if mode != "append" {
3344 self.notify_resource_list_changed();
3347 }
3348 Self::ok_content(outcome)
3349 }
3350 Err(e) => Self::err_content(e),
3351 }
3352 }
3353}
3354
3355#[prompt_router]
3358impl HyperMcpServer {
3359 #[prompt(
3361 name = "analyze-table",
3362 description = "Deep analysis of a single table: schema, sample, column stats, data quality"
3363 )]
3364 pub async fn analyze_table(
3365 &self,
3366 Parameters(args): Parameters<AnalyzeTableArgs>,
3367 ) -> Vec<PromptMessage> {
3368 let context = self.build_analyze_context(&args.table);
3369 vec![
3370 PromptMessage::new_text(
3371 PromptMessageRole::User,
3372 format!(
3373 "Analyze the `{}` table thoroughly.\n\n{}\n\nPlease:\n\
3374 1. Describe each column (what it likely represents based on name and sample values)\n\
3375 2. Compute basic statistics using the query tool: min/max/avg for numeric columns, distinct count and top values for text columns\n\
3376 3. Flag any data quality issues: unexpected NULLs, suspicious outliers, inconsistent formats\n\
3377 4. Summarize your findings in plain English",
3378 args.table, context
3379 ),
3380 ),
3381 PromptMessage::new_text(
3382 PromptMessageRole::Assistant,
3383 format!(
3384 "I'll analyze the `{}` table systematically. Let me start by examining the schema and sample, then run targeted queries for statistics and data quality.",
3385 args.table
3386 ),
3387 ),
3388 ]
3389 }
3390
3391 #[prompt(
3393 name = "compare-tables",
3394 description = "Compare two tables: schema alignment, common keys, JOIN opportunities"
3395 )]
3396 pub async fn compare_tables(
3397 &self,
3398 Parameters(args): Parameters<CompareTablesArgs>,
3399 ) -> Vec<PromptMessage> {
3400 let ctx_a = self.build_brief_context(&args.table_a);
3401 let ctx_b = self.build_brief_context(&args.table_b);
3402 vec![
3403 PromptMessage::new_text(
3404 PromptMessageRole::User,
3405 format!(
3406 "Compare these two tables:\n\n## Table A: `{}`\n{}\n\n## Table B: `{}`\n{}\n\nPlease:\n\
3407 1. Identify columns that appear in both tables (by name or semantic match)\n\
3408 2. Suggest likely JOIN keys and the JOIN type (inner, left, etc.)\n\
3409 3. Highlight schema differences (column types, nullability)\n\
3410 4. Propose 3-5 analytical queries that combine both tables and explain what each reveals",
3411 args.table_a, ctx_a, args.table_b, ctx_b
3412 ),
3413 ),
3414 PromptMessage::new_text(
3415 PromptMessageRole::Assistant,
3416 format!(
3417 "I'll compare `{}` and `{}` systematically — schema alignment first, then join keys, then analytical opportunities.",
3418 args.table_a, args.table_b
3419 ),
3420 ),
3421 ]
3422 }
3423
3424 #[prompt(
3426 name = "data-quality",
3427 description = "Systematic data quality assessment: NULL rates, duplicates, low cardinality, outliers"
3428 )]
3429 pub async fn data_quality(
3430 &self,
3431 Parameters(args): Parameters<DataQualityArgs>,
3432 ) -> Vec<PromptMessage> {
3433 let context = self.build_brief_context(&args.table);
3434 vec![
3435 PromptMessage::new_text(
3436 PromptMessageRole::User,
3437 format!(
3438 "Run a data quality assessment on the `{}` table.\n\n{}\n\nPlease use the query tool to check:\n\
3439 1. NULL rate per column — run SELECT COUNT(*) FILTER (WHERE col IS NULL) / COUNT(*) for each column\n\
3440 2. Duplicate rows — compare COUNT(*) vs COUNT(DISTINCT *) or use GROUP BY\n\
3441 3. Low-cardinality columns — columns with suspiciously few distinct values\n\
3442 4. Numeric outliers — values more than 3 stddev from the mean\n\
3443 5. Date sanity — future dates or impossibly old dates in date/timestamp columns\n\n\
3444 Summarize findings with severity (critical / warning / info) and suggest remediation for each issue.",
3445 args.table, context
3446 ),
3447 ),
3448 PromptMessage::new_text(
3449 PromptMessageRole::Assistant,
3450 format!(
3451 "I'll perform a systematic data quality assessment on `{}`. Let me run targeted queries for each check category.",
3452 args.table
3453 ),
3454 ),
3455 ]
3456 }
3457
3458 #[prompt(
3460 name = "suggest-queries",
3461 description = "Suggest analytical SQL queries for a table, optionally guided by a goal"
3462 )]
3463 pub async fn suggest_queries(
3464 &self,
3465 Parameters(args): Parameters<SuggestQueriesArgs>,
3466 ) -> Vec<PromptMessage> {
3467 let context = self.build_analyze_context(&args.table);
3468 let goal_section = match args.goal.as_deref() {
3469 Some(g) if !g.is_empty() => format!("\n\nSpecific goal: {g}"),
3470 _ => String::new(),
3471 };
3472 vec![
3473 PromptMessage::new_text(
3474 PromptMessageRole::User,
3475 format!(
3476 "Given the `{}` table:\n\n{}{}\n\nSuggest 5 analytical SQL queries that would be useful for exploring this data. \
3477 For each query, provide:\n\
3478 - A descriptive title\n\
3479 - The exact SQL (valid for Hyper / PostgreSQL-compatible syntax)\n\
3480 - One sentence explaining what insight it reveals\n\n\
3481 Prefer queries that use aggregations, GROUP BY, window functions, or CTEs to demonstrate the power of SQL analytics.",
3482 args.table, context, goal_section
3483 ),
3484 ),
3485 PromptMessage::new_text(
3486 PromptMessageRole::Assistant,
3487 format!(
3488 "Based on the schema and sample of `{}`, here are 5 analytical queries.",
3489 args.table
3490 ),
3491 ),
3492 ]
3493 }
3494}
3495
3496#[derive(Debug, Clone)]
3505pub enum ResourceBody {
3506 Json(Value),
3508 Text {
3511 mime_type: String,
3513 content: String,
3515 },
3516}
3517
3518impl ResourceBody {
3519 #[must_use]
3521 pub fn mime_type(&self) -> &str {
3522 match self {
3523 ResourceBody::Json(_) => "application/json",
3524 ResourceBody::Text { mime_type, .. } => mime_type,
3525 }
3526 }
3527
3528 #[must_use]
3531 pub fn to_text(&self) -> String {
3532 match self {
3533 ResourceBody::Json(v) => {
3534 serde_json::to_string_pretty(v).unwrap_or_else(|_| v.to_string())
3535 }
3536 ResourceBody::Text { content, .. } => content.clone(),
3537 }
3538 }
3539
3540 #[must_use]
3543 pub fn as_json(&self) -> Option<&Value> {
3544 match self {
3545 ResourceBody::Json(v) => Some(v),
3546 ResourceBody::Text { .. } => None,
3547 }
3548 }
3549}
3550
3551impl HyperMcpServer {
3552 pub fn resource_body_for_uri(&self, uri: &str) -> Result<Option<ResourceBody>, McpError> {
3571 if uri == "hyper://workspace" {
3572 return self
3573 .with_engine(super::engine::Engine::status)
3574 .map(|v| Some(ResourceBody::Json(v)));
3575 }
3576 if uri == "hyper://tables" {
3577 return self
3578 .with_engine(|engine| {
3579 engine
3580 .describe_tables()
3581 .map(|tables| json!({ "tables": tables }))
3582 })
3583 .map(|v| Some(ResourceBody::Json(v)));
3584 }
3585 if uri == "hyper://readme" {
3586 return self.build_readme_body().map(Some);
3587 }
3588 if let Some(name) = uri
3589 .strip_prefix("hyper://tables/")
3590 .and_then(|rest| rest.strip_suffix("/schema"))
3591 {
3592 let name = name.to_string();
3593 return self
3594 .with_engine(|engine| {
3595 let tables = engine.describe_tables()?;
3596 tables
3597 .into_iter()
3598 .find(|t| t.get("name").and_then(|v| v.as_str()) == Some(name.as_str()))
3599 .ok_or_else(|| {
3600 McpError::new(
3601 ErrorCode::TableNotFound,
3602 format!("Table '{name}' does not exist"),
3603 )
3604 })
3605 })
3606 .map(|v| Some(ResourceBody::Json(v)));
3607 }
3608 if let Some(name) = uri
3609 .strip_prefix("hyper://tables/")
3610 .and_then(|rest| rest.strip_suffix("/sample"))
3611 {
3612 let name = name.to_string();
3613 return self
3614 .with_engine(|engine| engine.sample_table(&name, TABLE_SAMPLE_ROWS))
3615 .map(|v| Some(ResourceBody::Json(v)));
3616 }
3617 if let Some(name) = uri
3618 .strip_prefix("hyper://tables/")
3619 .and_then(|rest| rest.strip_suffix("/csv-sample"))
3620 {
3621 let name = name.to_string();
3622 return self.build_csv_sample_body(&name).map(Some);
3623 }
3624 if let Some(name) = uri
3625 .strip_prefix("hyper://queries/")
3626 .and_then(|rest| rest.strip_suffix("/definition"))
3627 {
3628 return self.build_saved_query_definition(name).map(Some);
3629 }
3630 if let Some(name) = uri
3631 .strip_prefix("hyper://queries/")
3632 .and_then(|rest| rest.strip_suffix("/result"))
3633 {
3634 return self.build_saved_query_result(name).map(Some);
3635 }
3636 Ok(None)
3637 }
3638
3639 fn build_saved_query_definition(&self, name: &str) -> Result<ResourceBody, McpError> {
3643 let store = Arc::clone(&self.saved_queries);
3644 let name = name.to_string();
3645 let query = self.with_saved_query_store(|engine| store.get(engine, &name))?;
3646 match query {
3647 Some(q) => Ok(ResourceBody::Json(q.to_json())),
3648 None => Err(McpError::new(
3649 ErrorCode::TableNotFound,
3650 format!("No saved query named '{name}'"),
3651 )),
3652 }
3653 }
3654
3655 fn build_saved_query_result(&self, name: &str) -> Result<ResourceBody, McpError> {
3660 let store = Arc::clone(&self.saved_queries);
3661 let name_owned = name.to_string();
3662 let query = self
3663 .with_saved_query_store(|engine| store.get(engine, &name_owned))?
3664 .ok_or_else(|| {
3665 McpError::new(
3666 ErrorCode::TableNotFound,
3667 format!("No saved query named '{name_owned}'"),
3668 )
3669 })?;
3670 let sql = query.sql.clone();
3671 let body = self.with_engine(|engine| {
3672 let timer = crate::stats::StatsTimer::start();
3673 let rows = engine.execute_query_to_json(&sql)?;
3674 let elapsed = timer.elapsed_ms();
3675 let result_size = serde_json::to_string(&rows).map_or(0, |s| s.len() as u64);
3676 let stats = crate::stats::QueryStats {
3677 operation: "saved_query".into(),
3678 rows_returned: rows.len() as u64,
3679 rows_scanned: 0,
3680 elapsed_ms: elapsed,
3681 result_size_bytes: result_size,
3682 tables_touched: vec![],
3683 };
3684 Ok(json!({
3685 "name": query.name,
3686 "sql": Self::fmt_sql(&query.sql),
3687 "result": rows,
3688 "stats": stats.to_json(),
3689 }))
3690 })?;
3691 Ok(ResourceBody::Json(body))
3692 }
3693
3694 #[must_use]
3701 pub fn list_resource_uris(&self) -> Vec<String> {
3702 let mut uris = vec![
3703 "hyper://workspace".to_string(),
3704 "hyper://tables".to_string(),
3705 "hyper://readme".to_string(),
3706 ];
3707 if let Ok(tables) = self.with_engine(super::engine::Engine::describe_tables) {
3708 for table in tables {
3712 if let Some(name) = table.get("name").and_then(|v| v.as_str()) {
3713 uris.push(format!("hyper://tables/{name}/schema"));
3714 uris.push(format!("hyper://tables/{name}/sample"));
3715 uris.push(format!("hyper://tables/{name}/csv-sample"));
3716 }
3717 }
3718 }
3719 let store = Arc::clone(&self.saved_queries);
3720 if let Ok(saved) = self.with_saved_query_store(|engine| store.list(engine)) {
3721 for q in saved {
3722 uris.push(format!("hyper://queries/{}/definition", q.name));
3723 uris.push(format!("hyper://queries/{}/result", q.name));
3724 }
3725 }
3726 uris
3727 }
3728
3729 fn build_readme_body(&self) -> Result<ResourceBody, McpError> {
3737 let status = self.with_engine(super::engine::Engine::status)?;
3738 let tables = self
3739 .with_engine(super::engine::Engine::describe_tables)
3740 .unwrap_or_default();
3741
3742 let workspace_mode = status
3743 .get("workspace_mode")
3744 .and_then(|v| v.as_str())
3745 .unwrap_or("unknown");
3746 let workspace_path = status
3747 .get("workspace_path")
3748 .and_then(|v| v.as_str())
3749 .unwrap_or("");
3750 let read_only = status
3751 .get("read_only")
3752 .and_then(serde_json::Value::as_bool)
3753 .unwrap_or(false);
3754 let table_count = tables.len();
3755
3756 let mut md = String::new();
3757 md.push_str("# HyperDB workspace\n\n");
3758 let _ = writeln!(
3759 md,
3760 "- Mode: **{workspace_mode}**{}\n",
3761 if read_only { " (read-only)" } else { "" }
3762 );
3763 if !workspace_path.is_empty() {
3764 let _ = writeln!(md, "- Path: `{workspace_path}`\n");
3765 }
3766 let _ = write!(md, "- Tables: **{table_count}**\n\n");
3767
3768 if tables.is_empty() {
3769 md.push_str(
3770 "_No tables loaded yet._ Use the `load_file` or `load_data` tools to \
3771 ingest CSV / JSON / Parquet / Arrow IPC data; call `inspect_file` \
3772 first if you're unsure of the schema.\n",
3773 );
3774 } else {
3775 md.push_str("## Tables\n\n");
3776 md.push_str("| Table | Rows | Columns |\n");
3777 md.push_str("|---|---:|---|\n");
3778 for t in &tables {
3779 let name = t.get("name").and_then(|v| v.as_str()).unwrap_or("?");
3780 let rows = t
3781 .get("row_count")
3782 .and_then(serde_json::Value::as_i64)
3783 .unwrap_or(0);
3784 let cols: Vec<String> = t
3785 .get("columns")
3786 .and_then(|v| v.as_array())
3787 .map(|arr| {
3788 arr.iter()
3789 .filter_map(|c| {
3790 let n = c.get("name")?.as_str()?;
3791 let ty = c.get("type")?.as_str()?;
3792 Some(format!("`{n}` {ty}"))
3793 })
3794 .collect()
3795 })
3796 .unwrap_or_default();
3797 let _ = writeln!(md, "| `{name}` | {rows} | {} |\n", cols.join(", "));
3798 }
3799 md.push('\n');
3800 md.push_str("## Related resources\n\n");
3801 for t in &tables {
3802 if let Some(name) = t.get("name").and_then(|v| v.as_str()) {
3803 let _ = write!(md, "- `hyper://tables/{name}/schema` — JSON schema and row count\n\
3804 - `hyper://tables/{name}/sample` — first {TABLE_SAMPLE_ROWS} rows as JSON\n\
3805 - `hyper://tables/{name}/csv-sample` — first {TABLE_CSV_SAMPLE_ROWS} rows as CSV\n");
3806 }
3807 }
3808 md.push('\n');
3809 }
3810
3811 md.push_str(
3812 "## Tool hints\n\n\
3813 - `query(sql)` — read-only SQL (SELECT / WITH / EXPLAIN / SHOW / VALUES).\n\
3814 - `execute(sql)` — DDL/DML (disabled in read-only mode).\n\
3815 - `sample(table, n)` — configurable row sample; the fixed-size\n \
3816 `hyper://tables/{name}/sample` resource uses n=5.\n\
3817 - `inspect_file(path)` — dry-run schema inference before loading.\n\
3818 - `chart(sql, chart_type, ...)` — render a PNG/SVG from a query.\n\
3819 - `export(sql|table, path, format)` — write to CSV / Parquet / Arrow IPC / .hyper.\n",
3820 );
3821
3822 Ok(ResourceBody::Text {
3823 mime_type: "text/markdown".into(),
3824 content: md,
3825 })
3826 }
3827
3828 fn build_csv_sample_body(&self, table: &str) -> Result<ResourceBody, McpError> {
3832 let sample =
3833 self.with_engine(|engine| engine.sample_table(table, TABLE_CSV_SAMPLE_ROWS))?;
3834
3835 let header: Vec<String> = sample
3839 .get("schema")
3840 .and_then(|v| v.as_array())
3841 .map(|cols| {
3842 cols.iter()
3843 .filter_map(|c| c.get("name").and_then(|n| n.as_str()).map(String::from))
3844 .collect()
3845 })
3846 .filter(|v: &Vec<String>| !v.is_empty())
3847 .or_else(|| {
3848 sample
3849 .get("rows")
3850 .and_then(|v| v.as_array())
3851 .and_then(|rows| rows.first())
3852 .and_then(|r| r.as_object())
3853 .map(|o| o.keys().cloned().collect())
3854 })
3855 .unwrap_or_default();
3856
3857 let mut wtr = csv::Writer::from_writer(Vec::<u8>::new());
3858 if !header.is_empty() {
3859 wtr.write_record(&header).map_err(|e| {
3860 McpError::new(
3861 ErrorCode::InternalError,
3862 format!("Failed to write CSV header: {e}"),
3863 )
3864 })?;
3865 }
3866 if let Some(rows) = sample.get("rows").and_then(|v| v.as_array()) {
3867 for row in rows {
3868 let record: Vec<String> = header
3869 .iter()
3870 .map(|col| row.get(col).map(value_to_csv_cell).unwrap_or_default())
3871 .collect();
3872 wtr.write_record(&record).map_err(|e| {
3873 McpError::new(
3874 ErrorCode::InternalError,
3875 format!("Failed to write CSV row: {e}"),
3876 )
3877 })?;
3878 }
3879 }
3880 let bytes = wtr.into_inner().map_err(|e| {
3881 McpError::new(
3882 ErrorCode::InternalError,
3883 format!("Failed to finalize CSV: {e}"),
3884 )
3885 })?;
3886 let content = String::from_utf8(bytes).map_err(|e| {
3887 McpError::new(
3888 ErrorCode::InternalError,
3889 format!("CSV produced invalid UTF-8: {e}"),
3890 )
3891 })?;
3892
3893 Ok(ResourceBody::Text {
3894 mime_type: "text/csv".into(),
3895 content,
3896 })
3897 }
3898
3899 fn build_analyze_context(&self, table: &str) -> String {
3902 match self.with_engine(|engine| engine.sample_table(table, 10)) {
3903 Ok(sample) => format!(
3904 "Schema and sample:\n```json\n{}\n```",
3905 serde_json::to_string_pretty(&sample).unwrap_or_else(|_| sample.to_string())
3906 ),
3907 Err(e) => format!("(Could not load table context: {e})"),
3908 }
3909 }
3910
3911 fn build_brief_context(&self, table: &str) -> String {
3913 match self.with_engine(|engine| engine.sample_table(table, 5)) {
3914 Ok(sample) => format!(
3915 "```json\n{}\n```",
3916 serde_json::to_string_pretty(&sample).unwrap_or_else(|_| sample.to_string())
3917 ),
3918 Err(e) => format!("(Could not load table context: {e})"),
3919 }
3920 }
3921}
3922
3923#[tool_handler]
3926#[prompt_handler]
3927impl ServerHandler for HyperMcpServer {
3928 fn get_info(&self) -> ServerInfo {
3929 let sql_dialect = "\n\
3930\n\
3931SQL DIALECT — Salesforce Data Cloud SQL (PostgreSQL-compatible with extensions).\n\
3932Key differences from standard PostgreSQL an LLM should know:\n\
3933\n\
3934TYPES\n\
3935- Supported: SMALLINT, INTEGER/INT, BIGINT, REAL/FLOAT4, DOUBLE PRECISION/FLOAT8,\n\
3936 NUMERIC(p,s)/DECIMAL(p,s), BOOLEAN, TEXT, CHAR(n), VARCHAR(n), BYTES,\n\
3937 DATE, TIME, TIMESTAMP, TIMESTAMPTZ, INTERVAL, and arrays of any atomic type\n\
3938- NUMERIC precision > 18 requires .hyper file format version 3 (default in this MCP)\n\
3939- No SERIAL / BIGSERIAL / UUID / JSON / JSONB / geometry types\n\
3940\n\
3941SELECT / QUERY\n\
3942- LIMIT / OFFSET work as in PostgreSQL; TOP N is also accepted\n\
3943- LATERAL is optional: subqueries in FROM always see preceding FROM items implicitly\n\
3944- DISTINCT ON (expr, ...) is supported\n\
3945- FROM clause is optional (can evaluate expressions without a table)\n\
3946- Function calls may appear directly in the FROM list\n\
3947- information_schema and pg_catalog do NOT exist; use the describe/sample tools\n\
3948\n\
3949GROUP BY / AGGREGATION\n\
3950- GROUPING SETS, ROLLUP, CUBE all supported\n\
3951- GROUP BY DISTINCT removes duplicate grouping sets before processing\n\
3952- FILTER (WHERE ...) clause supported on aggregate calls\n\
3953- Ordered-set aggregates: MODE(), PERCENTILE_CONT(), PERCENTILE_DISC() with WITHIN GROUP (ORDER BY ...)\n\
3954- APPROX_COUNT_DISTINCT() for fast approximate cardinality\n\
3955- GROUPING() function identifies which columns are aggregated in GROUPING SETS\n\
3956\n\
3957WINDOW FUNCTIONS\n\
3958- Standard: row_number, rank, dense_rank, percent_rank, cume_dist, ntile, lag, lead,\n\
3959 first_value, last_value, nth_value\n\
3960- Hyper extension: modified_rank() — like rank() but assigns the LOWEST rank on ties\n\
3961- IGNORE NULLS / RESPECT NULLS supported on last_value only\n\
3962- nth_value supports FROM FIRST / FROM LAST\n\
3963- Frame modes: ROWS, RANGE, GROUPS; EXCLUDE CURRENT ROW / GROUP / TIES / NO OTHERS\n\
3964- Window-specific functions do NOT support DISTINCT or ORDER BY in their argument list\n\
3965\n\
3966SET-RETURNING FUNCTIONS (usable in FROM)\n\
3967- unnest(array) — expands an array to rows; supports WITH ORDINALITY\n\
3968- generate_series(start, stop [, step]) — numeric and datetime variants\n\
3969- external(path, format => '...') — reads Parquet, CSV, Iceberg etc. directly from files\n\
3970\n\
3971SET OPERATORS\n\
3972- UNION, INTERSECT, EXCEPT all supported; INTERSECT binds tighter than UNION/EXCEPT\n\
3973- ORDER BY and LIMIT/OFFSET can appear on parenthesized sub-expressions or the final result\n\
3974\n\
3975CTEs\n\
3976- WITH and WITH RECURSIVE both supported\n\
3977- CTEs evaluate once per query execution even if referenced multiple times\n\
3978\n\
3979IDENTIFIERS\n\
3980- Unquoted identifiers are folded to lowercase; double-quote to preserve case or use special chars\n\
3981- Quote names containing uppercase letters, digits at the start, or special characters\n\
3982\n\
3983NOT AVAILABLE IN HYPER (Data 360 / Data Cloud-only features)\n\
3984- AI functions: AI_CLASSIFY, AI_SENTIMENT, and other Data Cloud AI scalar functions\n\
3985- Data Cloud federation / streaming-specific functions\n\
3986\n\
3987Full SQL reference: https://developer.salesforce.com/docs/data/data-cloud-query-guide/references/dc-sql-reference";
3988
3989 let header = if self.read_only {
3990 "HyperDB MCP (read-only): SQL analytics for LLM workflows. Query existing tables, \
3991 sample data, export results. Mutating operations are disabled. \
3992 Call get_readme for a concise tool index, parameter rules, and usage examples."
3993 } else {
3994 "HyperDB MCP: instant SQL analytics for LLM workflows. Load data (CSV, JSON, Parquet, \
3995 Arrow IPC, Apache Iceberg), query with SQL, export results (Parquet, Iceberg, Arrow IPC, \
3996 CSV, Hyper). Use query for SELECT and execute for DDL/DML. \
3997 Call get_readme for a concise tool index, parameter rules, and usage examples."
3998 };
3999 let instructions = format!("{header}{sql_dialect}");
4000 let mut server_info = Implementation::default();
4001 server_info.name = "HyperDB".into();
4002 server_info.title = Some("HyperDB — Hyper SQL Analytics".into());
4003 server_info.version = env!("CARGO_PKG_VERSION").into();
4004 server_info.description = Some(
4005 "MCP server for Tableau Hyper: instant SQL analytics over \
4006 CSV, JSON, Parquet, Arrow IPC, and Apache Iceberg with schema inference, \
4007 partial schema overrides, full-file numeric widening, and \
4008 dry-run file inspection. SQL dialect is PostgreSQL-compatible with \
4009 extensions (Salesforce Data Cloud SQL). Full SQL reference: \
4010 https://developer.salesforce.com/docs/data/data-cloud-query-guide/references/dc-sql-reference/data-cloud-sql-context.html"
4011 .into(),
4012 );
4013
4014 let mut info = ServerInfo::default();
4015 info.instructions = Some(instructions);
4016 info.server_info = server_info;
4017 info.capabilities = ServerCapabilities::builder()
4018 .enable_tools()
4019 .enable_prompts()
4020 .enable_resources()
4021 .enable_resources_subscribe()
4026 .enable_resources_list_changed()
4027 .build();
4028 info
4029 }
4030
4031 async fn initialize(
4032 &self,
4033 request: InitializeRequestParams,
4034 context: RequestContext<RoleServer>,
4035 ) -> Result<InitializeResult, rmcp::ErrorData> {
4036 let name = &request.client_info.name;
4037 let version = &request.client_info.version;
4038 let label = if version.is_empty() {
4039 name.clone()
4040 } else {
4041 format!("{name} {version}")
4042 };
4043 if let Ok(mut guard) = self.client_name.lock() {
4044 *guard = Some(label);
4045 }
4046 context.peer.set_peer_info(request);
4047 Ok(self.get_info())
4048 }
4049
4050 async fn subscribe(
4059 &self,
4060 request: SubscribeRequestParams,
4061 context: RequestContext<RoleServer>,
4062 ) -> Result<(), rmcp::ErrorData> {
4063 self.subscriptions.subscribe(&request.uri, context.peer);
4064 Ok(())
4065 }
4066
4067 async fn unsubscribe(
4072 &self,
4073 request: UnsubscribeRequestParams,
4074 context: RequestContext<RoleServer>,
4075 ) -> Result<(), rmcp::ErrorData> {
4076 self.subscriptions.unsubscribe(&request.uri, &context.peer);
4077 Ok(())
4078 }
4079
4080 async fn list_resources(
4086 &self,
4087 _request: Option<PaginatedRequestParams>,
4088 _context: RequestContext<RoleServer>,
4089 ) -> Result<ListResourcesResult, rmcp::ErrorData> {
4090 let mut resources = vec![
4091 RawResource {
4092 uri: "hyper://workspace".into(),
4093 name: "Workspace Info".into(),
4094 title: Some("Hyper Workspace".into()),
4095 description: Some("Workspace mode, table count, total rows, disk usage".into()),
4096 mime_type: Some("application/json".into()),
4097 size: None,
4098 icons: None,
4099 meta: None,
4100 }
4101 .no_annotation(),
4102 RawResource {
4103 uri: "hyper://tables".into(),
4104 name: "All Tables".into(),
4105 title: Some("All Tables".into()),
4106 description: Some("List of all tables with column schemas and row counts".into()),
4107 mime_type: Some("application/json".into()),
4108 size: None,
4109 icons: None,
4110 meta: None,
4111 }
4112 .no_annotation(),
4113 RawResource {
4114 uri: "hyper://readme".into(),
4115 name: "Workspace Readme".into(),
4116 title: Some("HyperDB workspace readme".into()),
4117 description: Some(
4118 "Markdown overview of the workspace: tables, row counts, related \
4119 resources, and tool hints for LLMs orienting themselves."
4120 .into(),
4121 ),
4122 mime_type: Some("text/markdown".into()),
4123 size: None,
4124 icons: None,
4125 meta: None,
4126 }
4127 .no_annotation(),
4128 ];
4129
4130 if let Ok(tables) = self.with_engine(super::engine::Engine::describe_tables) {
4131 for table in tables {
4135 if let Some(name) = table.get("name").and_then(|v| v.as_str()) {
4136 let row_count = table
4137 .get("row_count")
4138 .and_then(serde_json::Value::as_i64)
4139 .unwrap_or(0);
4140 resources.push(
4141 RawResource {
4142 uri: format!("hyper://tables/{name}/schema"),
4143 name: format!("Schema of {name}"),
4144 title: Some(format!("{name} schema")),
4145 description: Some(format!(
4146 "Column schema and row count ({row_count} rows) for table '{name}'"
4147 )),
4148 mime_type: Some("application/json".into()),
4149 size: None,
4150 icons: None,
4151 meta: None,
4152 }
4153 .no_annotation(),
4154 );
4155 resources.push(
4156 RawResource {
4157 uri: format!("hyper://tables/{name}/sample"),
4158 name: format!("Sample of {name}"),
4159 title: Some(format!("{name} sample (JSON)")),
4160 description: Some(format!(
4161 "First {TABLE_SAMPLE_ROWS} rows of '{name}' as JSON, with schema"
4162 )),
4163 mime_type: Some("application/json".into()),
4164 size: None,
4165 icons: None,
4166 meta: None,
4167 }
4168 .no_annotation(),
4169 );
4170 resources.push(
4171 RawResource {
4172 uri: format!("hyper://tables/{name}/csv-sample"),
4173 name: format!("CSV sample of {name}"),
4174 title: Some(format!("{name} sample (CSV)")),
4175 description: Some(format!(
4176 "First {TABLE_CSV_SAMPLE_ROWS} rows of '{name}' as CSV"
4177 )),
4178 mime_type: Some("text/csv".into()),
4179 size: None,
4180 icons: None,
4181 meta: None,
4182 }
4183 .no_annotation(),
4184 );
4185 }
4186 }
4187 }
4188
4189 let store = Arc::clone(&self.saved_queries);
4190 if let Ok(saved) = self.with_saved_query_store(|engine| store.list(engine)) {
4191 for q in saved {
4192 let desc = q
4193 .description
4194 .clone()
4195 .unwrap_or_else(|| format!("Saved read-only SQL query '{}'", q.name));
4196 resources.push(
4197 RawResource {
4198 uri: format!("hyper://queries/{}/definition", q.name),
4199 name: format!("Query: {}", q.name),
4200 title: Some(format!("{} (definition)", q.name)),
4201 description: Some(format!("SQL + metadata for saved query '{}'", q.name)),
4202 mime_type: Some("application/json".into()),
4203 size: None,
4204 icons: None,
4205 meta: None,
4206 }
4207 .no_annotation(),
4208 );
4209 resources.push(
4210 RawResource {
4211 uri: format!("hyper://queries/{}/result", q.name),
4212 name: format!("Result: {}", q.name),
4213 title: Some(format!("{} (result)", q.name)),
4214 description: Some(format!("{desc} — re-runs on every read")),
4215 mime_type: Some("application/json".into()),
4216 size: None,
4217 icons: None,
4218 meta: None,
4219 }
4220 .no_annotation(),
4221 );
4222 }
4223 }
4224
4225 Ok(ListResourcesResult {
4226 resources,
4227 next_cursor: None,
4228 meta: None,
4229 })
4230 }
4231
4232 async fn list_resource_templates(
4235 &self,
4236 _request: Option<PaginatedRequestParams>,
4237 _context: RequestContext<RoleServer>,
4238 ) -> Result<ListResourceTemplatesResult, rmcp::ErrorData> {
4239 let templates = vec![
4240 RawResourceTemplate {
4241 uri_template: "hyper://tables/{name}/schema".into(),
4242 name: "Table Schema".into(),
4243 title: Some("Table Schema".into()),
4244 description: Some(
4245 "Column schema, types, nullability, and row count for a named table".into(),
4246 ),
4247 mime_type: Some("application/json".into()),
4248 icons: None,
4249 }
4250 .no_annotation(),
4251 RawResourceTemplate {
4252 uri_template: "hyper://tables/{name}/sample".into(),
4253 name: "Table Sample (JSON)".into(),
4254 title: Some("Table Sample".into()),
4255 description: Some(
4256 "First few rows of a named table as JSON, with schema. For a \
4257 configurable row count use the `sample` tool instead."
4258 .into(),
4259 ),
4260 mime_type: Some("application/json".into()),
4261 icons: None,
4262 }
4263 .no_annotation(),
4264 RawResourceTemplate {
4265 uri_template: "hyper://tables/{name}/csv-sample".into(),
4266 name: "Table Sample (CSV)".into(),
4267 title: Some("Table Sample (CSV)".into()),
4268 description: Some(
4269 "First few rows of a named table as CSV, header-first, for \
4270 spreadsheet and Pandas consumers."
4271 .into(),
4272 ),
4273 mime_type: Some("text/csv".into()),
4274 icons: None,
4275 }
4276 .no_annotation(),
4277 RawResourceTemplate {
4278 uri_template: "hyper://queries/{name}/definition".into(),
4279 name: "Saved Query Definition".into(),
4280 title: Some("Saved Query Definition".into()),
4281 description: Some(
4282 "Stored SQL plus metadata (description, created_at) for a saved \
4283 query registered via the `save_query` tool."
4284 .into(),
4285 ),
4286 mime_type: Some("application/json".into()),
4287 icons: None,
4288 }
4289 .no_annotation(),
4290 RawResourceTemplate {
4291 uri_template: "hyper://queries/{name}/result".into(),
4292 name: "Saved Query Result".into(),
4293 title: Some("Saved Query Result".into()),
4294 description: Some(
4295 "Live result of a saved query. The stored SQL re-runs on every \
4296 resource read — no caching, always fresh."
4297 .into(),
4298 ),
4299 mime_type: Some("application/json".into()),
4300 icons: None,
4301 }
4302 .no_annotation(),
4303 ];
4304 Ok(ListResourceTemplatesResult {
4305 resource_templates: templates,
4306 next_cursor: None,
4307 meta: None,
4308 })
4309 }
4310
4311 async fn read_resource(
4316 &self,
4317 request: ReadResourceRequestParams,
4318 _context: RequestContext<RoleServer>,
4319 ) -> Result<ReadResourceResult, rmcp::ErrorData> {
4320 let uri = &request.uri;
4321 let (mime_type, text) = match self.resource_body_for_uri(uri) {
4322 Ok(Some(body)) => (body.mime_type().to_string(), body.to_text()),
4323 Ok(None) => {
4324 return Err(rmcp::ErrorData::invalid_params(
4325 format!("Unknown resource URI: {uri}"),
4326 None,
4327 ));
4328 }
4329 Err(e) => {
4330 let err_val = serde_json::to_value(&e).unwrap_or(Value::String(e.to_string()));
4333 let text =
4334 serde_json::to_string_pretty(&json!({ "error": err_val })).unwrap_or_default();
4335 ("application/json".into(), text)
4336 }
4337 };
4338
4339 Ok(ReadResourceResult::new(vec![
4340 ResourceContents::TextResourceContents {
4341 uri: uri.clone(),
4342 mime_type: Some(mime_type),
4343 text,
4344 meta: None,
4345 },
4346 ]))
4347 }
4348}
4349
4350fn validate_execute_batch(stmts: &[String]) -> Result<(), McpError> {
4374 use crate::engine::strip_leading_sql_comments;
4375
4376 if stmts.is_empty() {
4377 return Err(McpError::new(
4378 ErrorCode::InvalidArgument,
4379 "`sql` must be a non-empty array of SQL statements.",
4380 )
4381 .with_suggestion("Pass at least one statement: `sql: [\"INSERT INTO t VALUES (1)\"]`."));
4382 }
4383
4384 let mut has_schema_change = false;
4385 let mut has_data_mutation = false;
4386
4387 for (idx, stmt) in stmts.iter().enumerate() {
4388 if strip_leading_sql_comments(stmt).trim().is_empty() {
4389 return Err(McpError::new(
4390 ErrorCode::InvalidArgument,
4391 format!("`sql[{idx}]` is empty or contains only whitespace/comments."),
4392 )
4393 .with_suggestion("Remove the empty element or replace it with a real statement."));
4394 }
4395 match classify_statement(stmt) {
4404 StatementKind::ReadOnly => {
4405 return Err(McpError::new(
4406 ErrorCode::SqlError,
4407 format!(
4408 "`sql[{idx}]` is a read-only statement; the `execute` tool is for DDL/DML."
4409 ),
4410 )
4411 .with_suggestion(
4412 "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 …`).",
4413 ));
4414 }
4415 StatementKind::TransactionControl => {
4416 return Err(McpError::new(
4424 ErrorCode::InvalidArgument,
4425 format!(
4426 "`sql[{idx}]` is a transaction-control statement (BEGIN / COMMIT / ROLLBACK / SAVEPOINT); these are not allowed in `execute` batches."
4427 ),
4428 )
4429 .with_suggestion(
4430 "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.",
4431 ));
4432 }
4433 StatementKind::Ddl => has_schema_change = true,
4434 StatementKind::Dml => has_data_mutation = true,
4435 StatementKind::Other => {}
4436 }
4437 }
4438
4439 if has_schema_change && has_data_mutation {
4440 return Err(McpError::new(
4441 ErrorCode::InvalidArgument,
4442 "Cannot mix DDL (CREATE/DROP/ALTER/TRUNCATE/RENAME) with DML (INSERT/UPDATE/DELETE/COPY/MERGE) in one `execute` batch.",
4443 )
4444 .with_suggestion(
4445 "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.",
4446 ));
4447 }
4448
4449 if has_schema_change && stmts.len() > 1 {
4450 return Err(McpError::new(
4451 ErrorCode::InvalidArgument,
4452 "Multi-statement DDL batches are not supported.",
4453 )
4454 .with_suggestion(
4455 "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.",
4456 ));
4457 }
4458
4459 Ok(())
4460}
4461
4462fn value_to_csv_cell(v: &Value) -> String {
4468 match v {
4469 Value::Null => String::new(),
4470 Value::Bool(b) => b.to_string(),
4471 Value::Number(n) => n.to_string(),
4472 Value::String(s) => s.clone(),
4473 _ => v.to_string(),
4474 }
4475}
4476
4477fn detect_format(data: &str) -> String {
4480 let trimmed = data.trim_start();
4481 if trimmed.starts_with('[') || trimmed.starts_with('{') {
4482 "json".into()
4483 } else {
4484 "csv".into()
4485 }
4486}
4487
4488fn rand_suffix() -> String {
4492 use std::time::{SystemTime, UNIX_EPOCH};
4493 let t = SystemTime::now()
4494 .duration_since(UNIX_EPOCH)
4495 .unwrap_or_default();
4496 format!("{}", t.as_nanos() % 1_000_000_000)
4497}
4498
4499fn qualified_name(engine: &Engine, db: Option<&str>, table: &str) -> String {
4510 let alias = db.map_or_else(|| engine.primary_db_name(), str::to_string);
4511 let escaped_alias = alias.replace('"', "\"\"");
4512 let escaped_table = table.replace('"', "\"\"");
4513 format!("\"{escaped_alias}\".\"public\".\"{escaped_table}\"")
4514}
4515
4516fn target_exists(engine: &Engine, db: Option<&str>, table: &str) -> Result<bool, McpError> {
4521 let sql = format!(
4522 "SELECT 1 FROM {} LIMIT 0",
4523 qualified_name(engine, db, table)
4524 );
4525 match engine.execute_query_to_json(&sql) {
4526 Ok(_) => Ok(true),
4527 Err(e) => {
4528 let m = e.message.to_lowercase();
4529 let missing = m.contains("does not exist")
4530 || m.contains("undefined table")
4531 || e.message.contains("42P01");
4532 if missing {
4533 Ok(false)
4534 } else {
4535 Err(e)
4536 }
4537 }
4538 }
4539}
4540
4541fn count_rows(engine: &Engine, db: Option<&str>, table: &str) -> i64 {
4546 let sql = format!(
4547 "SELECT COUNT(*) AS cnt FROM {}",
4548 qualified_name(engine, db, table)
4549 );
4550 engine
4551 .execute_query_to_json(&sql)
4552 .ok()
4553 .and_then(|rows| {
4554 rows.first()
4555 .and_then(|r| r.get("cnt").and_then(serde_json::Value::as_i64))
4556 })
4557 .unwrap_or(0)
4558}
4559
4560fn probe_table_count(engine: &Engine, alias: &str) -> Value {
4564 let escaped_alias = alias.replace('"', "\"\"");
4565 let sql = format!(
4566 "SELECT COUNT(*) AS cnt FROM \"{escaped_alias}\".pg_catalog.pg_tables WHERE schemaname = 'public'"
4567 );
4568 match engine.execute_query_to_json(&sql) {
4569 Ok(rows) => rows
4570 .first()
4571 .and_then(|r| r.get("cnt").and_then(serde_json::Value::as_i64))
4572 .map_or(Value::Null, |n| json!(n)),
4573 Err(_) => Value::Null,
4574 }
4575}
4576
4577fn prepare_temp_attachments(
4581 specs: &[AttachSpec],
4582 read_only: bool,
4583) -> Result<Vec<AttachRequest>, McpError> {
4584 let mut out = Vec::with_capacity(specs.len());
4585 for spec in specs {
4586 let writable = spec.writable.unwrap_or(false);
4587 if writable && read_only {
4588 return Err(McpError::new(
4589 ErrorCode::ReadOnlyViolation,
4590 format!(
4591 "temp_attach for alias '{}' requested writable:true but the server is --read-only",
4592 spec.alias
4593 ),
4594 ));
4595 }
4596 let on_missing = attach::OnMissing::parse(spec.on_missing.as_deref())?;
4597 if on_missing == attach::OnMissing::Create && !writable {
4598 return Err(McpError::new(
4599 ErrorCode::InvalidArgument,
4600 format!(
4601 "temp_attach alias '{}' has on_missing='create' but writable is not true — \
4602 an empty .hyper file that cannot be written to cannot be populated.",
4603 spec.alias
4604 ),
4605 ));
4606 }
4607 let source = match spec.kind.as_str() {
4608 "local_file" => {
4609 let Some(raw) = spec.path.as_deref() else {
4610 return Err(McpError::new(
4611 ErrorCode::InvalidArgument,
4612 format!("temp_attach alias '{}' requires a 'path'", spec.alias),
4613 ));
4614 };
4615 let resolved = match on_missing {
4616 attach::OnMissing::Error => attach::validate_local_path(raw)?,
4617 attach::OnMissing::Create => attach::validate_local_path_for_create(raw)?,
4618 };
4619 AttachSource::LocalFile { path: resolved }
4620 }
4621 other => {
4622 return Err(McpError::new(
4623 ErrorCode::InvalidArgument,
4624 format!(
4625 "Unsupported temp_attach kind '{other}' for alias '{}'. Only 'local_file' is supported today.",
4626 spec.alias
4627 ),
4628 ));
4629 }
4630 };
4631 attach::validate_alias(&spec.alias)?;
4632 out.push(AttachRequest {
4633 alias: spec.alias.clone(),
4634 source,
4635 writable,
4636 on_missing,
4637 });
4638 }
4639 Ok(out)
4640}
4641
4642fn perform_copy(
4647 engine: &Engine,
4648 mode: &str,
4649 target_db: Option<&str>,
4650 target_table: &str,
4651 sql_body: &str,
4652) -> Result<Value, McpError> {
4653 let qualified = qualified_name(engine, target_db, target_table);
4654 let exists = target_exists(engine, target_db, target_table)?;
4655 let timer = crate::stats::StatsTimer::start();
4656
4657 match mode {
4658 "create" => {
4659 if exists {
4660 return Err(McpError::new(
4661 ErrorCode::InvalidArgument,
4662 format!(
4663 "Target '{target_table}' already exists. Use mode='append' to add rows or mode='replace' to drop and recreate."
4664 ),
4665 ));
4666 }
4667 engine.execute_command(&format!("CREATE TABLE {qualified} AS {sql_body}"))?;
4668 }
4669 "append" => {
4670 if !exists {
4671 return Err(McpError::new(
4672 ErrorCode::InvalidArgument,
4673 format!(
4674 "Target '{target_table}' does not exist. Use mode='create' to create it from the query or mode='replace' to drop and recreate."
4675 ),
4676 ));
4677 }
4678 engine.execute_command(&format!("INSERT INTO {qualified} {sql_body}"))?;
4679 }
4680 "replace" => {
4681 engine.execute_command(&format!("DROP TABLE IF EXISTS {qualified}"))?;
4690 engine.execute_command(&format!("CREATE TABLE {qualified} AS {sql_body}"))?;
4691 }
4692 other => {
4693 return Err(McpError::new(
4694 ErrorCode::InvalidArgument,
4695 format!("copy_query mode '{other}' is not supported"),
4696 ));
4697 }
4698 }
4699
4700 let elapsed_ms = timer.elapsed_ms();
4701 let row_count = count_rows(engine, target_db, target_table);
4702 Ok(json!({
4703 "target_table": target_table,
4704 "target_database": target_db.unwrap_or(LOCAL_ALIAS),
4705 "mode": mode,
4706 "row_count": row_count,
4707 "stats": { "operation": "copy_query", "elapsed_ms": elapsed_ms },
4708 }))
4709}
4710
4711#[cfg(test)]
4712mod validate_execute_batch_tests {
4713 use super::*;
4714
4715 fn s(v: &[&str]) -> Vec<String> {
4716 v.iter().map(|x| (*x).to_string()).collect()
4717 }
4718
4719 #[test]
4720 fn rejects_empty_array() {
4721 let err = validate_execute_batch(&[]).unwrap_err();
4722 assert_eq!(err.code, ErrorCode::InvalidArgument);
4723 }
4724
4725 #[test]
4726 fn rejects_whitespace_only_element() {
4727 let err = validate_execute_batch(&s(&[" "])).unwrap_err();
4728 assert_eq!(err.code, ErrorCode::InvalidArgument);
4729 assert!(err.message.contains("sql[0]"));
4730 let err = validate_execute_batch(&s(&["INSERT INTO t VALUES (1)", "/* */"])).unwrap_err();
4731 assert!(err.message.contains("sql[1]"));
4732 }
4733
4734 #[test]
4735 fn rejects_read_only_element() {
4736 let err =
4737 validate_execute_batch(&s(&["INSERT INTO t VALUES (1)", "SELECT 1"])).unwrap_err();
4738 assert_eq!(err.code, ErrorCode::SqlError);
4739 assert!(err.message.contains("sql[1]"));
4740 }
4741
4742 #[test]
4743 fn rejects_transaction_control_in_batch() {
4744 for sql in [
4747 "BEGIN",
4748 "COMMIT",
4749 "ROLLBACK",
4750 "SAVEPOINT sp1",
4751 "START TRANSACTION",
4752 "END",
4753 "RELEASE SAVEPOINT sp1",
4754 ] {
4755 let err = validate_execute_batch(&s(&["INSERT INTO t VALUES (1)", sql])).unwrap_err();
4756 assert_eq!(err.code, ErrorCode::InvalidArgument, "for `{sql}`");
4757 assert!(
4758 err.message.contains("transaction-control"),
4759 "for `{sql}`: {}",
4760 err.message
4761 );
4762 }
4763 }
4764
4765 #[test]
4766 fn rejects_transaction_control_singleton() {
4767 let err = validate_execute_batch(&s(&["BEGIN"])).unwrap_err();
4772 assert_eq!(err.code, ErrorCode::InvalidArgument);
4773 }
4774
4775 #[test]
4776 fn rejects_ddl_dml_mix() {
4777 let err =
4778 validate_execute_batch(&s(&["CREATE TABLE x (i INT)", "INSERT INTO x VALUES (1)"]))
4779 .unwrap_err();
4780 assert_eq!(err.code, ErrorCode::InvalidArgument);
4781 assert!(err.message.contains("DDL"));
4782 }
4783
4784 #[test]
4785 fn rejects_multi_ddl() {
4786 let err = validate_execute_batch(&s(&["CREATE TABLE a (i INT)", "CREATE TABLE b (j INT)"]))
4787 .unwrap_err();
4788 assert_eq!(err.code, ErrorCode::InvalidArgument);
4789 assert!(err.message.contains("Multi-statement DDL"));
4790 }
4791
4792 #[test]
4793 fn allows_single_ddl() {
4794 validate_execute_batch(&s(&["CREATE TABLE a (i INT)"])).unwrap();
4795 }
4796
4797 #[test]
4798 fn allows_multi_dml() {
4799 validate_execute_batch(&s(&[
4800 "UPDATE settings SET value = 'x' WHERE key = 'k'",
4801 "INSERT INTO settings (key, value) SELECT 'k', 'x' WHERE NOT EXISTS (SELECT 1 FROM settings WHERE key = 'k')",
4802 ]))
4803 .unwrap();
4804 }
4805
4806 #[test]
4807 fn allows_other_kinds() {
4808 validate_execute_batch(&s(&["SET schema_search_path = 'mydb'"])).unwrap();
4812 }
4813
4814 #[test]
4815 fn allows_trailing_semicolon() {
4816 validate_execute_batch(&s(&["INSERT INTO t VALUES (1);"])).unwrap();
4817 }
4818}