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