1use crate::attach::{self, AttachRegistry, AttachRequest, AttachSource, LOCAL_ALIAS};
15use crate::chart::{render_chart, ChartFormat, ChartOptions, ChartType};
16use crate::engine::{classify_statement, is_read_only_sql, Engine, StatementKind};
17use crate::error::{ErrorCode, McpError};
18use crate::export::{export_to_file, ExportOptions};
19use crate::ingest::{
20 detect_file_format, ingest_csv, ingest_csv_file, ingest_csv_file_async, ingest_json,
21 ingest_json_file, ingest_json_file_async, InferredFileFormat, IngestOptions,
22};
23use crate::ingest_arrow::{
24 ingest_arrow_ipc_file, ingest_arrow_ipc_file_async, ingest_parquet_file,
25 ingest_parquet_file_async,
26};
27use crate::saved_queries::{build_store, SavedQuery, SavedQueryStore};
28use crate::subscriptions::{
29 uris_for_table_change, uris_for_workspace_change, SubscriptionRegistry,
30};
31use base64::Engine as _;
32use rmcp::handler::server::router::prompt::PromptRouter;
33use rmcp::handler::server::router::tool::ToolRouter;
34use rmcp::handler::server::wrapper::Parameters;
35use rmcp::model::{
36 AnnotateAble, CallToolResult, Content, GetPromptRequestParams, GetPromptResult, Implementation,
37 InitializeRequestParams, InitializeResult, ListPromptsResult, ListResourceTemplatesResult,
38 ListResourcesResult, PaginatedRequestParams, PromptMessage, PromptMessageRole, RawResource,
39 RawResourceTemplate, ReadResourceRequestParams, ReadResourceResult, ResourceContents,
40 ServerCapabilities, ServerInfo, SubscribeRequestParams, UnsubscribeRequestParams,
41};
42use rmcp::service::RequestContext;
43use rmcp::{
44 prompt, prompt_handler, prompt_router, tool, tool_handler, tool_router, RoleServer,
45 ServerHandler,
46};
47use schemars::JsonSchema;
48use serde::Deserialize;
49use serde_json::{json, Value};
50use sqlformat::{FormatOptions, Indent, QueryParams as SqlQueryParams};
51use std::fmt::Write as _;
52use std::sync::{Arc, Mutex};
53
54#[expect(
55 unused_imports,
56 reason = "imported for use in doc comments that reference the type path"
57)]
58use rmcp::model::RawTextContent;
59
60const TABLE_SAMPLE_ROWS: u64 = 5;
64
65const TABLE_CSV_SAMPLE_ROWS: u64 = 20;
69
70#[derive(Debug, Deserialize, JsonSchema)]
101pub struct QueryDataParams {
102 pub data: String,
104 pub sql: String,
107 pub format: Option<String>,
110 pub table_name: Option<String>,
112 pub schema: Option<Value>,
117}
118
119#[derive(Debug, Deserialize, JsonSchema)]
121pub struct QueryFileParams {
122 pub path: String,
124 pub sql: String,
127 pub table_name: Option<String>,
129 pub schema: Option<Value>,
133 pub json_extract_path: Option<String>,
139}
140
141#[derive(Debug, Deserialize, JsonSchema)]
143pub struct LoadDataParams {
144 pub table: String,
146 pub data: String,
148 pub format: Option<String>,
150 pub mode: Option<String>,
153 pub schema: Option<Value>,
156 pub database: Option<String>,
161 pub persist: Option<bool>,
165}
166
167#[derive(Debug, Deserialize, JsonSchema)]
169pub struct LoadFileParams {
170 pub table: String,
172 pub path: String,
174 pub mode: Option<String>,
179 pub schema: Option<Value>,
186 pub json_extract_path: Option<String>,
192 pub merge_key: Option<MergeKey>,
197 pub database: Option<String>,
201 pub persist: Option<bool>,
204}
205
206#[derive(Debug, JsonSchema)]
216#[schemars(
217 title = "MergeKey",
218 description = "Either a single column name (string) or a list of column names (array of strings)",
219 untagged
220)]
221pub enum MergeKey {
222 Single(String),
223 Multi(Vec<String>),
224}
225
226impl<'de> Deserialize<'de> for MergeKey {
227 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
228 where
229 D: serde::Deserializer<'de>,
230 {
231 use serde::de::Error;
232 let v = serde_json::Value::deserialize(deserializer)?;
233 match v {
234 serde_json::Value::String(s) => Ok(Self::Single(s)),
235 serde_json::Value::Array(arr) => {
236 let mut names = Vec::with_capacity(arr.len());
237 for (i, item) in arr.into_iter().enumerate() {
238 match item {
239 serde_json::Value::String(s) => names.push(s),
240 other => {
241 return Err(D::Error::custom(format!(
242 "merge_key array element [{i}] must be a string \
243 (column name); got {other}"
244 )));
245 }
246 }
247 }
248 Ok(Self::Multi(names))
249 }
250 other => Err(D::Error::custom(format!(
251 "merge_key must be a column name (string) or list of column names \
252 (array of strings); got {other}"
253 ))),
254 }
255 }
256}
257
258impl MergeKey {
259 pub fn into_vec(self) -> Option<Vec<String>> {
263 let v = match self {
264 Self::Single(s) => vec![s],
265 Self::Multi(v) => v,
266 };
267 if v.is_empty() || v.iter().any(String::is_empty) {
268 None
269 } else {
270 Some(v)
271 }
272 }
273}
274
275#[derive(Debug, Deserialize, JsonSchema)]
279pub struct LoadFilesEntry {
280 pub table: String,
282 pub path: String,
284 pub mode: Option<String>,
287 pub schema: Option<Value>,
289 pub json_extract_path: Option<String>,
291 pub merge_key: Option<MergeKey>,
294}
295
296#[derive(Debug, Deserialize, JsonSchema)]
298pub struct LoadFilesParams {
299 pub files: Vec<LoadFilesEntry>,
303 pub concurrency: Option<u32>,
309 pub database: Option<String>,
315 pub persist: Option<bool>,
318}
319
320fn validate_merge_args(
330 mode: &str,
331 merge_key: Option<MergeKey>,
332) -> Result<Option<Vec<String>>, McpError> {
333 match (mode, merge_key) {
334 ("merge", None) => Err(McpError::new(
335 ErrorCode::InvalidArgument,
336 "mode=merge requires merge_key (a column name or list of column names)",
337 )),
338 ("merge", Some(mk)) => mk.into_vec().map(Some).ok_or_else(|| {
339 McpError::new(
340 ErrorCode::InvalidArgument,
341 "merge_key must be a non-empty list of non-empty column names",
342 )
343 }),
344 (_, Some(_)) => Err(McpError::new(
345 ErrorCode::InvalidArgument,
346 "merge_key is only valid with mode=merge",
347 )),
348 (_, None) => Ok(None),
349 }
350}
351
352#[derive(Debug, Deserialize, JsonSchema)]
358pub struct LoadIcebergParams {
359 pub table: String,
361 pub path: String,
364 pub mode: Option<String>,
366 pub metadata_filename: Option<String>,
369 pub version_as_of: Option<i64>,
371}
372
373#[derive(Debug, Deserialize, JsonSchema)]
375pub struct QueryParams {
376 pub sql: String,
378 pub database: Option<String>,
382}
383
384#[derive(Debug, Deserialize, JsonSchema)]
386pub struct ExecuteParams {
387 pub sql: Vec<String>,
405 pub database: Option<String>,
409}
410
411#[derive(Debug, Deserialize, JsonSchema)]
413pub struct SampleParams {
414 pub table: String,
416 pub n: Option<u64>,
418 pub database: Option<String>,
421}
422
423#[derive(Debug, Default, Deserialize, JsonSchema)]
427pub struct DescribeParams {
428 pub table: Option<String>,
431 pub database: Option<String>,
435}
436
437#[derive(Debug, Deserialize, JsonSchema)]
439pub struct ChartParams {
440 pub sql: String,
442 pub chart_type: String,
444 pub x: Option<String>,
446 pub y: Option<String>,
448 pub series: Option<String>,
450 pub title: Option<String>,
452 pub format: Option<String>,
454 pub width: Option<u32>,
456 pub height: Option<u32>,
458 pub bins: Option<u32>,
460 pub x_as_category: Option<bool>,
466 pub x_range: Option<[f64; 2]>,
471 pub y_range: Option<[f64; 2]>,
474 pub color_map: Option<std::collections::HashMap<String, String>>,
479 pub label_points: Option<bool>,
484 pub output_path: Option<String>,
490 pub inline: Option<bool>,
496 pub overwrite: Option<bool>,
500 pub database: Option<String>,
504}
505
506#[derive(Debug, Deserialize, JsonSchema)]
508pub struct WatchDirectoryParams {
509 pub path: String,
511 pub table: String,
513 #[serde(default)]
516 pub max_concurrent: Option<u32>,
517 pub database: Option<String>,
526 pub persist: Option<bool>,
529}
530
531#[derive(Debug, Deserialize, JsonSchema)]
533pub struct UnwatchDirectoryParams {
534 pub path: String,
536}
537
538#[derive(Debug, Deserialize, JsonSchema)]
548pub struct InspectFileParams {
549 pub path: String,
552 pub sample_rows: Option<u32>,
556 pub json_extract_path: Option<String>,
560}
561
562#[derive(Debug, Deserialize, JsonSchema)]
564pub struct ExportParams {
565 pub sql: Option<String>,
567 pub table: Option<String>,
569 pub path: String,
571 pub format: String,
576 pub overwrite: Option<bool>,
580 pub format_options: Option<Value>,
598 pub database: Option<String>,
604}
605
606#[derive(Debug, Deserialize, JsonSchema)]
620pub struct SaveQueryParams {
621 pub name: String,
624 pub sql: String,
628 pub description: Option<String>,
630}
631
632#[derive(Debug, Deserialize, JsonSchema)]
634pub struct DeleteQueryParams {
635 pub name: String,
638}
639
640#[derive(Debug, Deserialize, JsonSchema, Clone)]
645pub struct AttachSpec {
646 pub alias: String,
650 pub kind: String,
654 pub path: Option<String>,
657 pub writable: Option<bool>,
661 pub on_missing: Option<String>,
667}
668
669#[derive(Debug, Deserialize, JsonSchema)]
673pub struct AttachDatabaseParams {
674 pub alias: String,
678 pub kind: String,
680 pub path: Option<String>,
684 pub writable: Option<bool>,
688 pub on_missing: Option<String>,
699}
700
701#[derive(Debug, Deserialize, JsonSchema)]
703pub struct DetachDatabaseParams {
704 pub alias: String,
706}
707
708#[derive(Debug, Deserialize, JsonSchema)]
716pub struct CopyQueryParams {
717 pub sql: String,
722 pub target_table: String,
725 pub mode: String,
733 pub target_database: Option<String>,
737 pub temp_attach: Option<Vec<AttachSpec>>,
741}
742
743#[derive(Debug, Deserialize, JsonSchema)]
751pub struct SetTableMetadataParams {
752 pub table: String,
756 pub source_url: Option<String>,
758 pub source_description: Option<String>,
761 pub purpose: Option<String>,
764 pub license: Option<String>,
766 pub notes: Option<String>,
768 pub data_url: Option<String>,
773 pub database: Option<String>,
781}
782
783#[derive(Debug, Deserialize, JsonSchema)]
787pub struct AnalyzeTableArgs {
788 pub table: String,
790}
791
792#[derive(Debug, Deserialize, JsonSchema)]
794pub struct CompareTablesArgs {
795 pub table_a: String,
797 pub table_b: String,
799}
800
801#[derive(Debug, Deserialize, JsonSchema)]
803pub struct DataQualityArgs {
804 pub table: String,
806}
807
808#[derive(Debug, Deserialize, JsonSchema)]
810pub struct SuggestQueriesArgs {
811 pub table: String,
813 pub goal: Option<String>,
815}
816
817pub struct HyperMcpServer {
826 engine: Arc<Mutex<Option<Engine>>>,
827 catalog_ready: Arc<Mutex<bool>>,
833 watchers: Arc<crate::watcher::WatcherRegistry>,
834 saved_queries: Arc<dyn SavedQueryStore>,
835 subscriptions: Arc<SubscriptionRegistry>,
836 attachments: Arc<AttachRegistry>,
842 workspace_path: Option<String>,
846 read_only: bool,
847 no_daemon: bool,
849 last_heartbeat: std::sync::Mutex<std::time::Instant>,
851 client_name: std::sync::Mutex<Option<String>>,
855 #[expect(dead_code, reason = "constructed for rmcp 1.x macro-based dispatch")]
860 tool_router: ToolRouter<Self>,
861 #[expect(dead_code, reason = "constructed for rmcp 1.x macro-based dispatch")]
862 prompt_router: PromptRouter<Self>,
863}
864
865impl std::fmt::Debug for HyperMcpServer {
866 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
867 f.debug_struct("HyperMcpServer")
868 .field("persistent_path", &self.workspace_path)
869 .field("read_only", &self.read_only)
870 .field("no_daemon", &self.no_daemon)
871 .finish_non_exhaustive()
872 }
873}
874
875impl HyperMcpServer {
876 pub fn new(persistent_path: Option<String>, read_only: bool) -> Self {
896 Self::with_options(persistent_path, read_only, false)
897 }
898
899 pub fn with_no_daemon(
901 persistent_path: Option<String>,
902 read_only: bool,
903 no_daemon: bool,
904 ) -> Self {
905 Self::with_options(persistent_path, read_only, no_daemon)
906 }
907
908 fn with_options(persistent_path: Option<String>, read_only: bool, no_daemon: bool) -> Self {
909 let saved_queries: Arc<dyn SavedQueryStore> = build_store(persistent_path.as_deref());
912 Self {
913 engine: Arc::new(Mutex::new(None)),
914 catalog_ready: Arc::new(Mutex::new(false)),
915 watchers: Arc::new(crate::watcher::WatcherRegistry::new()),
916 saved_queries,
917 subscriptions: Arc::new(SubscriptionRegistry::new()),
918 attachments: Arc::new(AttachRegistry::new()),
923 workspace_path: persistent_path,
924 read_only,
925 no_daemon,
926 last_heartbeat: std::sync::Mutex::new(std::time::Instant::now()),
927 client_name: std::sync::Mutex::new(None),
928 tool_router: Self::tool_router(),
929 prompt_router: Self::prompt_router(),
930 }
931 }
932
933 #[must_use]
937 pub fn subscriptions_handle(&self) -> Arc<SubscriptionRegistry> {
938 Arc::clone(&self.subscriptions)
939 }
940
941 pub(crate) fn notify_table_changed(&self, table: &str) {
948 for uri in uris_for_table_change(table) {
949 self.subscriptions.notify_updated(&uri);
950 }
951 }
952
953 pub(crate) fn notify_workspace_changed(&self) {
958 for uri in uris_for_workspace_change() {
959 self.subscriptions.notify_updated(uri);
960 }
961 }
962
963 pub(crate) fn notify_resource_list_changed(&self) {
968 self.subscriptions.notify_list_changed();
969 }
970
971 fn client_name(&self) -> Option<String> {
974 self.client_name.lock().ok().and_then(|g| g.clone())
975 }
976
977 #[must_use]
980 pub fn engine_handle(&self) -> Arc<Mutex<Option<Engine>>> {
981 Arc::clone(&self.engine)
982 }
983
984 #[must_use]
986 pub fn watchers_handle(&self) -> Arc<crate::watcher::WatcherRegistry> {
987 Arc::clone(&self.watchers)
988 }
989
990 #[must_use]
993 pub fn attachments_handle(&self) -> Arc<AttachRegistry> {
994 Arc::clone(&self.attachments)
995 }
996
997 #[must_use]
999 pub fn is_read_only(&self) -> bool {
1000 self.read_only
1001 }
1002
1003 fn check_writable(&self, operation: &str) -> Result<(), McpError> {
1006 if self.read_only {
1007 Err(McpError::new(
1008 ErrorCode::ReadOnlyViolation,
1009 format!("Operation '{operation}' is not permitted in read-only mode"),
1010 ))
1011 } else {
1012 Ok(())
1013 }
1014 }
1015
1016 fn resolve_db(
1025 &self,
1026 engine: &Engine,
1027 database: Option<&str>,
1028 persist: Option<bool>,
1029 require_writable: bool,
1030 ) -> Result<Option<String>, McpError> {
1031 let effective = match (database, persist) {
1032 (Some(db), _) => Some(db),
1033 (None, Some(true)) => Some(Engine::PERSISTENT_ALIAS),
1034 _ => None,
1035 };
1036 let effective = effective.filter(|s| !s.eq_ignore_ascii_case(crate::attach::LOCAL_ALIAS));
1038
1039 let resolved = engine.resolve_target_db(effective)?;
1040 let primary = engine.primary_db_name();
1041
1042 if resolved == primary {
1043 return Ok(None);
1044 }
1045
1046 if require_writable && resolved != Engine::PERSISTENT_ALIAS {
1047 match self.attachments.get(&resolved) {
1048 None => {
1049 return Err(McpError::new(
1050 ErrorCode::InvalidArgument,
1051 format!(
1052 "database '{resolved}' is not attached. \
1053 Call attach_database first, or use \"persistent\"."
1054 ),
1055 ));
1056 }
1057 Some(entry) if !entry.writable => {
1058 return Err(McpError::new(
1059 ErrorCode::InvalidArgument,
1060 format!(
1061 "database '{resolved}' was attached read-only. \
1062 Re-attach with writable:true to write to it."
1063 ),
1064 ));
1065 }
1066 _ => {}
1067 }
1068 }
1069
1070 Ok(Some(resolved))
1071 }
1072
1073 fn ensure_engine(&self) -> Result<std::sync::MutexGuard<'_, Option<Engine>>, McpError> {
1082 let mut guard = self
1083 .engine
1084 .lock()
1085 .map_err(|_| McpError::new(ErrorCode::InternalError, "Lock poisoned"))?;
1086 if guard.is_none() {
1087 tracing::info!(
1088 persistent_db = self.workspace_path.as_deref().unwrap_or("<ephemeral-only>"),
1089 no_daemon = self.no_daemon,
1090 "initializing hyper engine"
1091 );
1092 let engine = if self.no_daemon {
1093 Engine::new_no_daemon(self.workspace_path.clone())?
1094 } else {
1095 Engine::new(self.workspace_path.clone())?
1096 };
1097 tracing::info!(
1098 ephemeral_path = %engine.ephemeral_path().display(),
1099 persistent_path = ?engine.persistent_path(),
1100 log_dir = %engine.log_dir().display(),
1101 "engine ready"
1102 );
1103 if let Err(e) = self.attachments.replay_all(&engine) {
1112 tracing::warn!(err = %e.message, "failed to replay attachments on new engine");
1113 }
1114 *guard = Some(engine);
1115 if let Ok(mut ready) = self.catalog_ready.lock() {
1119 *ready = false;
1120 }
1121 }
1122 Ok(guard)
1123 }
1124
1125 pub fn warm_up_engine(&self) {
1135 match self.ensure_engine() {
1136 Ok(_guard) => {
1137 tracing::info!("engine initialized eagerly at startup");
1138 }
1139 Err(e) => {
1140 tracing::warn!(
1141 err = %e.message,
1142 "eager engine initialization failed at startup; \
1143 will retry on first data-plane tool call"
1144 );
1145 }
1146 }
1147 }
1148
1149 fn ensure_catalog_ready(&self, engine: &Engine) {
1158 if self.read_only {
1159 return;
1160 }
1161 let Ok(mut ready) = self.catalog_ready.lock() else {
1162 return;
1163 };
1164 if *ready {
1165 return;
1166 }
1167 if let Err(e) = crate::table_catalog::ensure_exists(engine) {
1168 tracing::warn!(err = %e.message, "failed to ensure _table_catalog exists");
1169 }
1170 if let Err(e) = crate::table_catalog::reconcile(engine) {
1171 tracing::warn!(err = %e.message, "failed to reconcile _table_catalog on startup");
1172 }
1173 *ready = true;
1174 }
1175
1176 fn after_ingest_catalog_update(
1189 &self,
1190 engine: &Engine,
1191 table_name: &str,
1192 load_tool: &'static str,
1193 load_params: Option<&str>,
1194 row_count: Option<i64>,
1195 target_db: Option<&str>,
1196 ) {
1197 if let Err(e) = crate::table_catalog::upsert_stub_in(
1198 engine,
1199 table_name,
1200 load_tool,
1201 load_params,
1202 row_count,
1203 true,
1204 target_db,
1205 self.client_name().as_deref(),
1206 ) {
1207 tracing::warn!(
1208 table = %table_name,
1209 target_db = ?target_db,
1210 err = %e.message,
1211 "failed to update _table_catalog after ingest"
1212 );
1213 }
1214 }
1215
1216 #[expect(
1226 clippy::unused_self,
1227 reason = "&self required for method-call dispatch; body uses only engine + target_db"
1228 )]
1229 fn after_execute_catalog_update(&self, engine: &Engine, target_db: Option<&str>) {
1230 if let Err(e) = crate::table_catalog::reconcile_in(engine, None) {
1231 tracing::warn!(
1232 err = %e.message,
1233 "failed to reconcile persistent _table_catalog after execute"
1234 );
1235 }
1236 if let Some(alias) = target_db {
1237 if !alias.eq_ignore_ascii_case(Engine::PERSISTENT_ALIAS) {
1238 if let Err(e) = crate::table_catalog::reconcile_in(engine, Some(alias)) {
1239 tracing::warn!(
1240 target_db = alias,
1241 err = %e.message,
1242 "failed to reconcile user-DB _table_catalog after execute"
1243 );
1244 }
1245 }
1246 }
1247 }
1248
1249 fn with_engine<F, R>(&self, f: F) -> Result<R, McpError>
1258 where
1259 F: FnOnce(&Engine) -> Result<R, McpError>,
1260 {
1261 let mut guard = self.ensure_engine()?;
1262 let engine = guard.as_ref().expect("ensure_engine guarantees Some");
1263 self.ensure_catalog_ready(engine);
1268 if !self.no_daemon {
1273 self.maybe_send_heartbeat(engine.daemon_health_port());
1274 }
1275 let result = f(engine);
1276 if let Err(e) = &result {
1277 tracing::debug!(code = ?e.code, message = %e.message, "tool call returned error");
1278 if e.code == ErrorCode::ConnectionLost {
1279 tracing::warn!(
1280 "connection to hyperd lost or desynchronized ({}); \
1285 dropping engine so next call reconnects",
1286 e.message
1287 );
1288 *guard = None;
1289 if let Ok(mut ready) = self.catalog_ready.lock() {
1292 *ready = false;
1293 }
1294 if !self.no_daemon {
1298 crate::daemon::health::report_hyperd_error_to_daemon();
1299 }
1300 }
1301 }
1302 result
1303 }
1304
1305 fn maybe_send_heartbeat(&self, daemon_health_port: Option<u16>) {
1313 const HEARTBEAT_INTERVAL: std::time::Duration = std::time::Duration::from_secs(60);
1314 let should_send = self
1315 .last_heartbeat
1316 .lock()
1317 .is_ok_and(|guard| guard.elapsed() >= HEARTBEAT_INTERVAL);
1318 if should_send {
1319 if let Some(port) = daemon_health_port {
1320 let _ = crate::daemon::health::send_command(port, "HEARTBEAT");
1321 if let Ok(mut guard) = self.last_heartbeat.lock() {
1322 *guard = std::time::Instant::now();
1323 }
1324 }
1325 }
1326 }
1327
1328 fn status_degraded(&self) -> Value {
1341 let (hyperd_running, engine_block) = if let Some(info) =
1346 crate::daemon::discovery::discover()
1347 {
1348 (
1349 true,
1350 json!({
1351 "mode": "daemon",
1352 "hyperd_endpoint": info.hyperd_endpoint,
1353 "daemon_health_port": info.health_port,
1354 }),
1355 )
1356 } else if self.no_daemon {
1357 (
1359 false,
1360 json!({ "mode": "local", "hyperd_endpoint": null, "daemon_health_port": null }),
1361 )
1362 } else {
1363 (
1364 false,
1365 json!({ "mode": "daemon", "hyperd_endpoint": null, "daemon_health_port": null }),
1366 )
1367 };
1368
1369 let persistent_path = self
1370 .workspace_path
1371 .as_ref()
1372 .map_or(Value::Null, |p| Value::String(p.clone()));
1373
1374 let attachments: Vec<Value> = self
1375 .attachments
1376 .list()
1377 .iter()
1378 .map(super::attach::AttachedDb::to_json)
1379 .collect();
1380
1381 json!({
1382 "engine_busy": true,
1383 "hyperd_running": hyperd_running,
1384 "persistent_path": persistent_path,
1385 "has_persistent": self.workspace_path.is_some(),
1386 "engine": engine_block,
1387 "hyper_rust_api_version": crate::version::mcp_version_string(),
1388 "watchers": self.watchers.to_json(),
1389 "read_only": self.read_only,
1390 "attachments": attachments,
1391 })
1392 }
1393
1394 fn with_saved_query_store<F, R>(&self, f: F) -> Result<R, McpError>
1404 where
1405 F: FnOnce(Option<&Engine>) -> Result<R, McpError>,
1406 {
1407 if self.workspace_path.is_some() {
1408 self.with_engine(|engine| f(Some(engine)))
1409 } else {
1410 f(None)
1411 }
1412 }
1413
1414 #[expect(
1415 clippy::unnecessary_wraps,
1416 reason = "signature retained for API symmetry / future fallibility; returning Result/Option keeps callers from breaking when the function later grows failure cases"
1417 )]
1418 fn ok_content(val: Value) -> Result<CallToolResult, rmcp::ErrorData> {
1423 let text = serde_json::to_string_pretty(&val).unwrap_or_default();
1424 let mut result = CallToolResult::structured(val);
1425 result.content = vec![Content::text(text)];
1429 Ok(result)
1430 }
1431
1432 fn fmt_sql(sql: &str) -> String {
1435 let opts = FormatOptions {
1436 indent: Indent::Spaces(2),
1437 uppercase: Some(true),
1438 lines_between_queries: 1,
1439 ..FormatOptions::default()
1440 };
1441 let formatted = sqlformat::format(sql, &SqlQueryParams::None, &opts);
1442 if formatted.trim().is_empty() {
1443 sql.to_owned()
1444 } else {
1445 formatted
1446 }
1447 }
1448
1449 #[expect(
1450 clippy::unnecessary_wraps,
1451 reason = "signature retained for API symmetry / future fallibility; returning Result/Option keeps callers from breaking when the function later grows failure cases"
1452 )]
1453 #[expect(
1454 clippy::needless_pass_by_value,
1455 reason = "call-site ergonomics: function consumes logically-owned parameters, refactoring signatures is not worth per-site churn"
1456 )]
1457 fn err_content(e: McpError) -> Result<CallToolResult, rmcp::ErrorData> {
1462 let err_val = serde_json::to_value(&e).unwrap_or(Value::String(e.to_string()));
1463 let body = json!({"error": err_val});
1464 let text = serde_json::to_string_pretty(&body).unwrap_or_default();
1465 let mut result = CallToolResult::structured_error(body);
1466 result.content = vec![Content::text(text)];
1467 Ok(result)
1468 }
1469}
1470
1471#[tool_router]
1472impl HyperMcpServer {
1473 #[tool(
1475 description = "Ingest inline data (JSON or CSV) and run a SQL query in one call. Creates a temp table, queries, discards."
1476 )]
1477 fn query_data(
1478 &self,
1479 Parameters(params): Parameters<QueryDataParams>,
1480 ) -> Result<CallToolResult, rmcp::ErrorData> {
1481 let result = self.with_engine(|engine| {
1482 let tname = params.table_name.unwrap_or_else(|| "data".into());
1483 let temp_table = format!("_tmp_{}_{}", tname, rand_suffix());
1484 let fmt = params.format.unwrap_or_else(|| detect_format(¶ms.data));
1485 let schema_override = crate::schema::normalize_schema_param(params.schema.as_ref())?;
1486 let opts = IngestOptions {
1487 table: temp_table.clone(),
1488 mode: "replace".into(),
1489 schema_override,
1490 merge_key: None,
1491 target_db: None,
1492 };
1493
1494 let ingest_result = match fmt.as_str() {
1495 "csv" => ingest_csv(engine, ¶ms.data, &opts),
1496 _ => ingest_json(engine, ¶ms.data, &opts),
1497 }?;
1498
1499 let query_sql = params.sql.replace(&tname, &temp_table);
1500 let rows = engine.execute_query_to_json(&query_sql)?;
1501 let _ = engine.execute_command(&format!("DROP TABLE IF EXISTS \"{temp_table}\""));
1502
1503 Ok(json!({
1504 "sql": Self::fmt_sql(¶ms.sql),
1505 "result": rows,
1506 "stats": ingest_result.stats.to_json(),
1507 }))
1508 });
1509
1510 match result {
1511 Ok(val) => Self::ok_content(val),
1512 Err(e) => Self::err_content(e),
1513 }
1514 }
1515
1516 #[tool(
1518 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."
1519 )]
1520 fn query_file(
1521 &self,
1522 Parameters(params): Parameters<QueryFileParams>,
1523 ) -> Result<CallToolResult, rmcp::ErrorData> {
1524 let result = self.with_engine(|engine| {
1525 crate::attach::validate_input_path(¶ms.path, "data file")?;
1526 let stem = std::path::Path::new(¶ms.path)
1527 .file_stem()
1528 .and_then(|s| s.to_str())
1529 .unwrap_or("file")
1530 .to_string();
1531 let tname = params.table_name.unwrap_or_else(|| stem.clone());
1532 let temp_table = format!("_tmp_{}_{}", tname, rand_suffix());
1533 let schema_override = crate::schema::normalize_schema_param(params.schema.as_ref())?;
1534 let opts = IngestOptions {
1535 table: temp_table.clone(),
1536 mode: "replace".into(),
1537 schema_override,
1538 merge_key: None,
1539 target_db: None,
1540 };
1541
1542 let ingest_result = if let Some(ref json_path) = params.json_extract_path {
1543 let raw = std::fs::read_to_string(¶ms.path).map_err(|e| {
1544 McpError::new(
1545 ErrorCode::FileNotFound,
1546 format!("Cannot read file '{}': {e}", params.path),
1547 )
1548 })?;
1549 let extracted = crate::ingest::extract_json_path(&raw, json_path)?;
1550 let array_text = crate::ingest::normalize_json_or_jsonl(&extracted)?;
1551 let mut result = ingest_json(engine, &array_text, &opts)?;
1552 result.stats.operation = "query_file".into();
1553 result.stats.bytes_read = std::fs::metadata(¶ms.path).map_or(0, |m| m.len());
1554 result.stats.file_format = Some("json".into());
1555 result
1556 } else {
1557 match detect_file_format(std::path::Path::new(¶ms.path)) {
1558 InferredFileFormat::Parquet => ingest_parquet_file(engine, ¶ms.path, &opts),
1559 InferredFileFormat::ArrowIpc => {
1560 ingest_arrow_ipc_file(engine, ¶ms.path, &opts)
1561 }
1562 InferredFileFormat::Json => ingest_json_file(engine, ¶ms.path, &opts),
1563 InferredFileFormat::Csv => ingest_csv_file(engine, ¶ms.path, &opts),
1564 }?
1565 };
1566
1567 let query_sql = params.sql.replace(&tname, &temp_table);
1568 let rows = engine.execute_query_to_json(&query_sql)?;
1569 let _ = engine.execute_command(&format!("DROP TABLE IF EXISTS \"{temp_table}\""));
1570
1571 Ok(json!({
1572 "sql": Self::fmt_sql(¶ms.sql),
1573 "result": rows,
1574 "stats": ingest_result.stats.to_json(),
1575 }))
1576 });
1577
1578 match result {
1579 Ok(val) => Self::ok_content(val),
1580 Err(e) => Self::err_content(e),
1581 }
1582 }
1583
1584 #[tool(
1586 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))."
1587 )]
1588 fn load_data(
1589 &self,
1590 Parameters(params): Parameters<LoadDataParams>,
1591 ) -> Result<CallToolResult, rmcp::ErrorData> {
1592 if let Err(e) = self.check_writable("load_data") {
1593 return Self::err_content(e);
1594 }
1595 let table_name = params.table.clone();
1596 let mode = params.mode.clone().unwrap_or_else(|| "replace".into());
1601 let result = self.with_engine(|engine| {
1602 let target_db =
1603 self.resolve_db(engine, params.database.as_deref(), params.persist, true)?;
1604 let fmt = params.format.unwrap_or_else(|| detect_format(¶ms.data));
1605 let schema_override = crate::schema::normalize_schema_param(params.schema.as_ref())?;
1606 let opts = IngestOptions {
1607 table: params.table.clone(),
1608 mode: mode.clone(),
1609 schema_override,
1610 merge_key: None,
1611 target_db: target_db.clone(),
1612 };
1613
1614 let ingest_result = match fmt.as_str() {
1615 "csv" => ingest_csv(engine, ¶ms.data, &opts),
1616 _ => ingest_json(engine, ¶ms.data, &opts),
1617 }?;
1618
1619 let schema_json: Vec<Value> = ingest_result
1620 .schema
1621 .iter()
1622 .map(|c| {
1623 json!({
1624 "name": c.name,
1625 "type": c.hyper_type,
1626 "nullable": c.nullable,
1627 })
1628 })
1629 .collect();
1630
1631 {
1637 let load_params = serde_json::to_string(&json!({
1638 "mode": mode,
1639 "format": fmt,
1640 "database": target_db.as_deref().unwrap_or("local"),
1641 }))
1642 .ok();
1643 self.after_ingest_catalog_update(
1644 engine,
1645 ¶ms.table,
1646 "load_data",
1647 load_params.as_deref(),
1648 i64::try_from(ingest_result.rows).ok(),
1649 target_db.as_deref(),
1650 );
1651 }
1652
1653 Ok(json!({
1654 "rows": ingest_result.rows,
1655 "schema": schema_json,
1656 "stats": ingest_result.stats.to_json(),
1657 }))
1658 });
1659
1660 match result {
1661 Ok(val) => {
1662 self.notify_table_changed(&table_name);
1663 if mode == "replace" {
1664 self.notify_resource_list_changed();
1668 }
1669 Self::ok_content(val)
1670 }
1671 Err(e) => Self::err_content(e),
1672 }
1673 }
1674
1675 #[tool(
1677 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."
1678 )]
1679 fn load_file(
1680 &self,
1681 Parameters(params): Parameters<LoadFileParams>,
1682 ) -> Result<CallToolResult, rmcp::ErrorData> {
1683 if let Err(e) = self.check_writable("load_file") {
1684 return Self::err_content(e);
1685 }
1686 let table_name = params.table.clone();
1687 let mode = params.mode.clone().unwrap_or_else(|| "replace".into());
1688 let merge_key_vec = match validate_merge_args(&mode, params.merge_key) {
1692 Ok(v) => v,
1693 Err(e) => return Self::err_content(e),
1694 };
1695 let result = self.with_engine(|engine| {
1700 let target_db =
1701 self.resolve_db(engine, params.database.as_deref(), params.persist, true)?;
1702 crate::attach::validate_input_path(¶ms.path, "data file")?;
1703 let schema_override = crate::schema::normalize_schema_param(params.schema.as_ref())?;
1704 let opts = IngestOptions {
1705 table: params.table.clone(),
1706 mode: mode.clone(),
1707 schema_override,
1708 merge_key: merge_key_vec.clone(),
1709 target_db: target_db.clone(),
1710 };
1711
1712 let ingest_result = if let Some(ref json_path) = params.json_extract_path {
1713 let raw = std::fs::read_to_string(¶ms.path).map_err(|e| {
1714 McpError::new(
1715 ErrorCode::FileNotFound,
1716 format!("Cannot read file '{}': {e}", params.path),
1717 )
1718 })?;
1719 let extracted = crate::ingest::extract_json_path(&raw, json_path)?;
1720 let array_text = crate::ingest::normalize_json_or_jsonl(&extracted)?;
1721 let mut result = ingest_json(engine, &array_text, &opts)?;
1722 result.stats.operation = "load_file".into();
1723 result.stats.bytes_read = std::fs::metadata(¶ms.path).map_or(0, |m| m.len());
1724 result.stats.file_format = Some("json".into());
1725 result
1726 } else {
1727 match detect_file_format(std::path::Path::new(¶ms.path)) {
1728 InferredFileFormat::Parquet => ingest_parquet_file(engine, ¶ms.path, &opts),
1729 InferredFileFormat::ArrowIpc => {
1730 ingest_arrow_ipc_file(engine, ¶ms.path, &opts)
1731 }
1732 InferredFileFormat::Json => ingest_json_file(engine, ¶ms.path, &opts),
1733 InferredFileFormat::Csv => ingest_csv_file(engine, ¶ms.path, &opts),
1734 }?
1735 };
1736
1737 let schema_changed = ingest_result.stats.schema_changed;
1741
1742 let schema_json: Vec<Value> = ingest_result
1743 .schema
1744 .iter()
1745 .map(|c| {
1746 json!({
1747 "name": c.name,
1748 "type": c.hyper_type,
1749 "nullable": c.nullable,
1750 })
1751 })
1752 .collect();
1753
1754 {
1756 let load_params = serde_json::to_string(&json!({
1757 "source_path": params.path,
1758 "mode": mode,
1759 "schema": params.schema,
1760 "json_extract_path": params.json_extract_path,
1761 "merge_key": merge_key_vec,
1762 "database": target_db.as_deref().unwrap_or("local"),
1763 }))
1764 .ok();
1765 self.after_ingest_catalog_update(
1766 engine,
1767 ¶ms.table,
1768 "load_file",
1769 load_params.as_deref(),
1770 i64::try_from(ingest_result.rows).ok(),
1771 target_db.as_deref(),
1772 );
1773 }
1774
1775 Ok((
1776 json!({
1777 "rows": ingest_result.rows,
1778 "schema": schema_json,
1779 "stats": ingest_result.stats.to_json(),
1780 }),
1781 schema_changed,
1782 ))
1783 });
1784
1785 match result {
1786 Ok((val, schema_changed)) => {
1787 self.notify_table_changed(&table_name);
1788 if mode == "replace" || schema_changed {
1796 self.notify_resource_list_changed();
1797 }
1798 Self::ok_content(val)
1799 }
1800 Err(e) => Self::err_content(e),
1801 }
1802 }
1803
1804 #[tool(
1808 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.**"
1809 )]
1810 fn load_files(
1811 &self,
1812 Parameters(params): Parameters<LoadFilesParams>,
1813 ) -> Result<CallToolResult, rmcp::ErrorData> {
1814 use hyperdb_api::pool::{create_pool, PoolConfig};
1815 use hyperdb_api::CreateMode;
1816
1817 if let Err(e) = self.check_writable("load_files") {
1818 return Self::err_content(e);
1819 }
1820 if params.files.is_empty() {
1821 return Self::err_content(McpError::new(
1822 ErrorCode::EmptyData,
1823 "load_files: `files` must not be empty",
1824 ));
1825 }
1826
1827 for (idx, entry) in params.files.iter().enumerate() {
1834 if let Err(mut e) = crate::attach::validate_input_path(&entry.path, "data file") {
1835 e.message = format!("entry {idx} (table '{}'): {}", entry.table, e.message);
1836 return Self::err_content(e);
1837 }
1838 let mode = entry.mode.as_deref().unwrap_or("replace");
1839 if mode == "merge" || entry.merge_key.is_some() {
1840 return Self::err_content(McpError::new(
1841 ErrorCode::InvalidArgument,
1842 format!(
1843 "load_files does not support mode=merge yet (entry {idx}, table \
1844 '{}'). Call load_file once per file when you need merge semantics.",
1845 entry.table
1846 ),
1847 ));
1848 }
1849 }
1850
1851 let (endpoint, workspace, target_db) = match self.with_engine(|engine| {
1857 let target_db =
1858 self.resolve_db(engine, params.database.as_deref(), params.persist, true)?;
1859 let endpoint = engine.hyperd_endpoint()?;
1860 let workspace = match target_db.as_deref() {
1861 None => engine.ephemeral_path().to_string_lossy().to_string(),
1862 Some(alias) if alias.eq_ignore_ascii_case(Engine::PERSISTENT_ALIAS) => engine
1863 .persistent_path()
1864 .ok_or_else(|| {
1865 McpError::new(
1866 ErrorCode::InvalidArgument,
1867 "target 'persistent' but the server is in --ephemeral-only mode",
1868 )
1869 })?
1870 .to_string_lossy()
1871 .to_string(),
1872 Some(alias) => {
1873 let entry = self.attachments.get(alias).ok_or_else(|| {
1874 McpError::new(
1875 ErrorCode::InvalidArgument,
1876 format!("database '{alias}' is not attached"),
1877 )
1878 })?;
1879 let crate::attach::AttachSource::LocalFile { path } = &entry.source;
1880 path.to_string_lossy().to_string()
1881 }
1882 };
1883 Ok((endpoint, workspace, target_db))
1884 }) {
1885 Ok(v) => v,
1886 Err(e) => return Self::err_content(e),
1887 };
1888
1889 let file_count = params.files.len();
1892 let concurrency = params
1893 .concurrency
1894 .map_or(8, |n| n as usize)
1895 .min(file_count)
1896 .clamp(1, 16);
1897
1898 let pool = match create_pool(
1899 PoolConfig::new(endpoint, workspace)
1900 .create_mode(CreateMode::DoNotCreate)
1901 .max_size(concurrency),
1902 ) {
1903 Ok(p) => Arc::new(p),
1904 Err(e) => {
1905 return Self::err_content(McpError::new(
1906 ErrorCode::InternalError,
1907 format!("Failed to build connection pool for load_files: {e}"),
1908 ))
1909 }
1910 };
1911
1912 let Ok(rt) = tokio::runtime::Handle::try_current() else {
1915 return Self::err_content(McpError::new(
1916 ErrorCode::InternalError,
1917 "load_files must run inside a tokio runtime",
1918 ));
1919 };
1920
1921 #[derive(Default)]
1924 struct EntryOutcome {
1925 table: String,
1926 ok: Option<(u64, Vec<Value>, Value)>,
1927 err: Option<(ErrorCode, String)>,
1928 replace_mode: bool,
1929 }
1930
1931 let outcomes: Vec<EntryOutcome> = tokio::task::block_in_place(|| {
1932 rt.block_on(async {
1933 let mut set = tokio::task::JoinSet::new();
1934 for (idx, entry) in params.files.into_iter().enumerate() {
1935 let pool = Arc::clone(&pool);
1936 let entry_target_db = target_db.clone();
1937 set.spawn(async move {
1938 let mode = entry.mode.clone().unwrap_or_else(|| "replace".into());
1939 let replace_mode = mode == "replace";
1940 let mut out = EntryOutcome {
1941 table: entry.table.clone(),
1942 replace_mode,
1943 ..Default::default()
1944 };
1945
1946 let schema_override =
1951 match crate::schema::normalize_schema_param(entry.schema.as_ref()) {
1952 Ok(v) => v,
1953 Err(e) => {
1954 out.err = Some((e.code, e.message));
1955 return (idx, out);
1956 }
1957 };
1958 let _ = &entry_target_db;
1967 let opts = IngestOptions {
1968 table: entry.table.clone(),
1969 mode: mode.clone(),
1970 schema_override,
1971 merge_key: None,
1972 target_db: None,
1973 };
1974
1975 let mut conn = match pool.get().await {
1978 Ok(c) => c,
1979 Err(e) => {
1980 out.err = Some((
1981 ErrorCode::InternalError,
1982 format!("Failed to check out connection: {e}"),
1983 ));
1984 return (idx, out);
1985 }
1986 };
1987
1988 let ingest_res = if let Some(ref json_path) = entry.json_extract_path {
1993 let raw = match std::fs::read_to_string(&entry.path) {
1994 Ok(s) => s,
1995 Err(e) => {
1996 out.err = Some((
1997 ErrorCode::FileNotFound,
1998 format!("Cannot read file '{}': {e}", entry.path),
1999 ));
2000 return (idx, out);
2001 }
2002 };
2003 let extracted = match crate::ingest::extract_json_path(&raw, json_path)
2004 {
2005 Ok(v) => v,
2006 Err(e) => {
2007 out.err = Some((e.code, e.message));
2008 return (idx, out);
2009 }
2010 };
2011 let array_text =
2012 match crate::ingest::normalize_json_or_jsonl(&extracted) {
2013 Ok(v) => v,
2014 Err(e) => {
2015 out.err = Some((e.code, e.message));
2016 return (idx, out);
2017 }
2018 };
2019 crate::ingest::ingest_json_async(&mut conn, &array_text, &opts)
2020 .await
2021 .map(|mut r| {
2022 r.stats.operation = "load_file".into();
2023 r.stats.bytes_read =
2024 std::fs::metadata(&entry.path).map_or(0, |m| m.len());
2025 r.stats.file_format = Some("json".into());
2026 r
2027 })
2028 } else {
2029 match detect_file_format(std::path::Path::new(&entry.path)) {
2030 InferredFileFormat::Parquet => {
2031 ingest_parquet_file_async(&mut conn, &entry.path, &opts).await
2032 }
2033 InferredFileFormat::ArrowIpc => {
2034 ingest_arrow_ipc_file_async(&mut conn, &entry.path, &opts).await
2035 }
2036 InferredFileFormat::Json => {
2037 ingest_json_file_async(&mut conn, &entry.path, &opts).await
2038 }
2039 InferredFileFormat::Csv => {
2040 ingest_csv_file_async(&mut conn, &entry.path, &opts).await
2041 }
2042 }
2043 };
2044
2045 match ingest_res {
2046 Ok(r) => {
2047 let schema_json: Vec<Value> = r
2048 .schema
2049 .iter()
2050 .map(|c| {
2051 json!({
2052 "name": c.name,
2053 "type": c.hyper_type,
2054 "nullable": c.nullable,
2055 })
2056 })
2057 .collect();
2058 out.ok = Some((r.rows, schema_json, r.stats.to_json()));
2059 }
2060 Err(e) => {
2061 out.err = Some((e.code, e.message));
2062 }
2063 }
2064
2065 (idx, out)
2066 });
2067 }
2068
2069 let mut collected: Vec<Option<EntryOutcome>> =
2072 (0..file_count).map(|_| None).collect();
2073 while let Some(joined) = set.join_next().await {
2074 match joined {
2075 Ok((idx, outcome)) => collected[idx] = Some(outcome),
2076 Err(e) => {
2077 tracing::warn!("load_files task join error: {e}");
2080 }
2081 }
2082 }
2083 collected.into_iter().flatten().collect()
2084 })
2085 });
2086
2087 let mut any_replace_succeeded = false;
2091 let mut tables_to_notify: Vec<String> = Vec::new();
2092 let results_json: Vec<Value> = outcomes
2093 .iter()
2094 .map(|o| match (&o.ok, &o.err) {
2095 (Some((rows, schema, stats)), _) => {
2096 tables_to_notify.push(o.table.clone());
2097 if o.replace_mode {
2098 any_replace_succeeded = true;
2099 }
2100 json!({
2101 "table": o.table,
2102 "rows": rows,
2103 "schema": schema,
2104 "stats": stats,
2105 })
2106 }
2107 (None, Some((code, msg))) => json!({
2108 "table": o.table,
2109 "error": {
2110 "code": format!("{:?}", code),
2111 "message": msg,
2112 }
2113 }),
2114 (None, None) => json!({
2117 "table": o.table,
2118 "error": {
2119 "code": "InternalError",
2120 "message": "load_files task produced no outcome",
2121 }
2122 }),
2123 })
2124 .collect();
2125
2126 if let Err(e) = self.with_engine(|engine| {
2130 for o in &outcomes {
2131 if let Some((rows, _, _)) = &o.ok {
2132 self.after_ingest_catalog_update(
2133 engine,
2134 &o.table,
2135 "load_file",
2136 None,
2137 i64::try_from(*rows).ok(),
2138 target_db.as_deref(),
2139 );
2140 }
2141 }
2142 Ok(())
2143 }) {
2144 tracing::warn!("load_files: catalog update batch failed: {}", e.message);
2145 }
2146
2147 for t in &tables_to_notify {
2148 self.notify_table_changed(t);
2149 }
2150 if any_replace_succeeded {
2151 self.notify_resource_list_changed();
2152 }
2153
2154 let success_count = outcomes.iter().filter(|o| o.ok.is_some()).count();
2155 let failure_count = outcomes.len() - success_count;
2156
2157 Self::ok_content(json!({
2158 "results": results_json,
2159 "summary": {
2160 "total": outcomes.len(),
2161 "succeeded": success_count,
2162 "failed": failure_count,
2163 "concurrency": concurrency,
2164 }
2165 }))
2166 }
2167
2168 #[tool(
2171 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."
2172 )]
2173 fn load_iceberg(
2174 &self,
2175 Parameters(params): Parameters<LoadIcebergParams>,
2176 ) -> Result<CallToolResult, rmcp::ErrorData> {
2177 if let Err(e) = self.check_writable("load_iceberg") {
2178 return Self::err_content(e);
2179 }
2180 let table_name = params.table.clone();
2181 let mode = params.mode.clone().unwrap_or_else(|| "replace".into());
2182 let opts = crate::lakehouse::IcebergIngestOptions {
2183 table: params.table.clone(),
2184 mode: mode.clone(),
2185 metadata_filename: params.metadata_filename.clone(),
2186 version_as_of: params.version_as_of,
2187 };
2188
2189 let result = self.with_engine(|engine| {
2190 crate::attach::validate_input_path(¶ms.path, "iceberg table")?;
2192 let ingest_result =
2193 crate::lakehouse::ingest_iceberg_table(engine, ¶ms.path, &opts)?;
2194
2195 let schema_json: Vec<Value> = ingest_result
2196 .schema
2197 .iter()
2198 .map(|c| {
2199 json!({
2200 "name": c.name,
2201 "type": c.hyper_type,
2202 "nullable": c.nullable,
2203 })
2204 })
2205 .collect();
2206
2207 let load_params = serde_json::to_string(&json!({
2208 "source_path": params.path,
2209 "mode": mode,
2210 "format": "iceberg",
2211 "metadata_filename": params.metadata_filename,
2212 "version_as_of": params.version_as_of,
2213 }))
2214 .ok();
2215 self.after_ingest_catalog_update(
2216 engine,
2217 ¶ms.table,
2218 "load_iceberg",
2219 load_params.as_deref(),
2220 i64::try_from(ingest_result.rows).ok(),
2221 None,
2222 );
2223
2224 Ok(json!({
2225 "rows": ingest_result.rows,
2226 "schema": schema_json,
2227 "stats": ingest_result.stats.to_json(),
2228 }))
2229 });
2230
2231 match result {
2232 Ok(val) => {
2233 self.notify_table_changed(&table_name);
2234 if mode == "replace" {
2235 self.notify_resource_list_changed();
2236 }
2237 Self::ok_content(val)
2238 }
2239 Err(e) => Self::err_content(e),
2240 }
2241 }
2242
2243 #[tool(
2245 description = "Run a read-only SQL query (SELECT, WITH, EXPLAIN, SHOW, VALUES) against the workspace. For DDL/DML use the execute tool."
2246 )]
2247 fn query(
2248 &self,
2249 Parameters(params): Parameters<QueryParams>,
2250 ) -> Result<CallToolResult, rmcp::ErrorData> {
2251 let result = self.with_engine(|engine| {
2252 if !is_read_only_sql(¶ms.sql) {
2253 return Err(McpError::new(
2254 ErrorCode::SqlError,
2255 "The query tool only accepts read-only SQL (SELECT, WITH, EXPLAIN, SHOW, VALUES). Use the execute tool for DDL/DML.",
2256 ));
2257 }
2258 let target_db = self.resolve_db(engine, params.database.as_deref(), None, false)?;
2261 let _search_guard = match target_db {
2262 Some(ref alias) => Some(engine.scoped_search_path(alias)?),
2263 None => None,
2264 };
2265 const MAX_QUERY_ROWS: usize = 10_000;
2269
2270 let timer = crate::stats::StatsTimer::start();
2271 let mut rows = engine.execute_query_to_json(¶ms.sql)?;
2272 let total_rows = rows.len();
2273 let truncated = total_rows > MAX_QUERY_ROWS;
2274 if truncated {
2275 rows.truncate(MAX_QUERY_ROWS);
2276 }
2277 let elapsed = timer.elapsed_ms();
2278 let stats = crate::stats::QueryStats {
2279 operation: "query".into(),
2280 rows_returned: rows.len() as u64,
2281 rows_scanned: 0,
2282 elapsed_ms: elapsed,
2283 result_size_bytes: serde_json::to_string(&rows).map_or(0, |s| s.len() as u64),
2284 tables_touched: vec![],
2285 };
2286 let payload = if truncated {
2287 json!({
2288 "result": rows,
2289 "stats": stats.to_json(),
2290 "truncated": true,
2291 "total_rows": total_rows,
2292 "rows_returned": MAX_QUERY_ROWS,
2293 "hint": format!(
2294 "Result set has {total_rows} rows; only the first {MAX_QUERY_ROWS} \
2295 are shown. Add a LIMIT clause, aggregate with GROUP BY, or use \
2296 the `export` tool to write the full result to a file."
2297 ),
2298 })
2299 } else {
2300 json!({
2301 "result": rows,
2302 "stats": stats.to_json(),
2303 })
2304 };
2305 Ok((params.sql.clone(), payload))
2306 });
2307
2308 match result {
2309 Ok((sql, val)) => {
2310 let formatted_sql = Self::fmt_sql(&sql);
2311 let json_text = serde_json::to_string_pretty(&val).unwrap_or_default();
2312 Ok(CallToolResult::success(vec![
2313 Content::text(format!("```sql\n{formatted_sql}\n```")),
2314 Content::text(json_text),
2315 ]))
2316 }
2317 Err(e) => Self::err_content(e),
2318 }
2319 }
2320
2321 #[tool(
2323 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."
2324 )]
2325 fn execute(
2326 &self,
2327 Parameters(params): Parameters<ExecuteParams>,
2328 ) -> Result<CallToolResult, rmcp::ErrorData> {
2329 if let Err(e) = self.check_writable("execute") {
2330 return Self::err_content(e);
2331 }
2332 if let Err(e) = validate_execute_batch(¶ms.sql) {
2337 return Self::err_content(e);
2338 }
2339 let any_structural = params
2340 .sql
2341 .iter()
2342 .any(|s| matches!(classify_statement(s), StatementKind::Ddl));
2343 let result = self.with_engine(|engine| {
2344 let target_db = self.resolve_db(engine, params.database.as_deref(), None, true)?;
2348 let _search_guard = match target_db {
2349 Some(ref alias) => Some(engine.scoped_search_path(alias)?),
2350 None => None,
2351 };
2352 let total_timer = crate::stats::StatsTimer::start();
2353 let (per_statement, affected_total, operation): (Vec<Value>, u64, &'static str) =
2354 if params.sql.len() == 1 {
2355 let stmt = ¶ms.sql[0];
2359 let t = crate::stats::StatsTimer::start();
2360 let affected = engine.execute_command(stmt)?;
2361 (
2362 vec![json!({
2363 "sql": Self::fmt_sql(stmt),
2364 "affected_rows": affected,
2365 "elapsed_ms": t.elapsed_ms(),
2366 })],
2367 affected,
2368 "command",
2369 )
2370 } else {
2371 let stmts = ¶ms.sql;
2372 let (results, total) = engine.execute_in_transaction(|engine| {
2373 let mut out = Vec::with_capacity(stmts.len());
2374 let mut total: u64 = 0;
2375 for (idx, stmt) in stmts.iter().enumerate() {
2376 let t = crate::stats::StatsTimer::start();
2377 let affected = engine.execute_command(stmt).map_err(|e| {
2378 let rollback_note = format!(
2383 "Failing SQL: {sql}. All previous statements in this batch were rolled back.",
2384 sql = Self::fmt_sql(stmt)
2385 );
2386 let combined = match e.suggestion.as_deref() {
2387 Some(orig) => format!("{orig} | {rollback_note}"),
2388 None => rollback_note,
2389 };
2390 McpError::new(
2391 e.code,
2392 format!(
2393 "statement {} of {} failed: {}",
2394 idx + 1,
2395 stmts.len(),
2396 e.message
2397 ),
2398 )
2399 .with_suggestion(combined)
2400 })?;
2401 total = total.saturating_add(affected);
2405 out.push(json!({
2406 "sql": Self::fmt_sql(stmt),
2407 "affected_rows": affected,
2408 "elapsed_ms": t.elapsed_ms(),
2409 }));
2410 }
2411 Ok((out, total))
2412 })?;
2413 (results, total, "transaction")
2414 };
2415 let elapsed = total_timer.elapsed_ms();
2416 if any_structural {
2429 self.after_execute_catalog_update(engine, target_db.as_deref());
2430 }
2431 Ok(json!({
2432 "statements": per_statement.len(),
2433 "affected_rows": affected_total,
2434 "per_statement": per_statement,
2435 "stats": { "operation": operation, "elapsed_ms": elapsed },
2436 }))
2437 });
2438
2439 match result {
2440 Ok(val) => {
2441 self.notify_workspace_changed();
2446 if any_structural {
2447 self.notify_resource_list_changed();
2448 }
2449 Self::ok_content(val)
2450 }
2451 Err(e) => Self::err_content(e),
2452 }
2453 }
2454
2455 #[tool(
2457 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."
2458 )]
2459 fn sample(
2460 &self,
2461 Parameters(params): Parameters<SampleParams>,
2462 ) -> Result<CallToolResult, rmcp::ErrorData> {
2463 let result = self.with_engine(|engine| {
2464 let target_db = self.resolve_db(engine, params.database.as_deref(), None, false)?;
2465 let timer = crate::stats::StatsTimer::start();
2466 let n = params.n.unwrap_or(5);
2467 let mut sample = engine.sample_table_in(target_db.as_deref(), ¶ms.table, n)?;
2468 let elapsed = timer.elapsed_ms();
2469 if let Some(obj) = sample.as_object_mut() {
2470 obj.insert(
2471 "stats".into(),
2472 json!({ "operation": "sample", "elapsed_ms": elapsed }),
2473 );
2474 }
2475 Ok(sample)
2476 });
2477
2478 match result {
2479 Ok(val) => Self::ok_content(val),
2480 Err(e) => Self::err_content(e),
2481 }
2482 }
2483
2484 #[tool(
2486 description = "Render a chart (bar, line, scatter, or histogram) from a SQL query. Returns the PNG/SVG image inline by default so MCP clients can display it directly. Set `inline=false` to skip the inline bytes and write to disk only (keeps the MCP transcript small for batch workflows). Combine `inline=true` with `output_path` to get both.\n\n**Data shape:** The query must return long-format data with one numeric `y` column. For multi-series charts, use a `series` column to split by category. If your data is wide-format (multiple value columns), reshape it with `UNION ALL` into (label, series, value) tuples before charting.\n\n**DATE/TIMESTAMP x-axis:** Line and scatter charts auto-detect non-numeric x columns. DATE, TIMESTAMP, and TIMESTAMPTZ values render with a **proportional time axis** — gaps between data points reflect real wall-clock time (4.5 h gap and 17 h gap don't look the same). Tick labels are formatted in the input kind: `%Y-%m-%d` for DATE, `%Y-%m-%d %H:%M:%S` for TIMESTAMP, with the originating timezone offset preserved for TIMESTAMPTZ. TEXT x columns fall back to evenly-spaced categorical mode. Set `x_as_category: true` to force categorical layout on temporal data (useful when even spacing reads better than proportional gaps).\n\n- `output_path`: explicit destination file path. Parent directory is created automatically (no need to pre-create it). If omitted and `inline=true` (default), no file is written. If omitted and `inline=false`, a file is auto-generated under the system temp dir as `hyperdb-charts/chart-<ts>-<pid>-<n>.<ext>`.\n- `inline`: when true (default), return the image bytes inline. Without `output_path`, suppresses the disk write entirely. With `output_path`, writes to disk AND returns inline. Set to false for disk-only output.\n- `format`: \"png\" (default) or \"svg\". Auto-derived from `output_path` extension when omitted. A mismatch between `format` and the path extension returns `INVALID_ARGUMENT`.\n- `overwrite`: default true. Set false to refuse overwriting an existing file (returns `PERMISSION_DENIED`).\n- `x_range` / `y_range`: fix axis extents across multiple charts (e.g. x_range=[0,1500], y_range=[0,1]).\n- `color_map`: stable per-series hex colors (e.g. {\"India\":\"#e41a1c\",\"China\":\"#ff7f0e\"}).\n- `label_points=true`: annotate each point with its series name instead of showing a legend — best when each series has exactly one point."
2487 )]
2488 fn chart(
2489 &self,
2490 Parameters(params): Parameters<ChartParams>,
2491 ) -> Result<CallToolResult, rmcp::ErrorData> {
2492 let result = self.with_engine(|engine| {
2493 if !is_read_only_sql(¶ms.sql) {
2494 return Err(McpError::new(
2495 ErrorCode::SqlError,
2496 "The chart tool only accepts read-only SQL (SELECT, WITH, EXPLAIN, SHOW, VALUES).",
2497 ));
2498 }
2499
2500 if let Some(out) = params.output_path.as_deref() {
2503 crate::attach::validate_output_path(out, "chart output")?;
2504 }
2505 let format = crate::chart::resolve_chart_format(
2508 params.format.as_deref(),
2509 params.output_path.as_deref(),
2510 )?;
2511
2512 let target_db = self.resolve_db(engine, params.database.as_deref(), None, false)?;
2515 let _search_guard = match target_db {
2516 Some(ref alias) => Some(engine.scoped_search_path(alias)?),
2517 None => None,
2518 };
2519
2520 let timer = crate::stats::StatsTimer::start();
2521 let rows = engine.execute_query_to_json(¶ms.sql)?;
2522
2523 let color_map = params
2526 .color_map
2527 .as_ref()
2528 .map(|m| {
2529 m.iter()
2530 .filter_map(|(k, v)| {
2531 crate::chart::parse_hex_color(v)
2532 .map(|c| (k.clone(), c))
2533 })
2534 .collect::<std::collections::HashMap<_, _>>()
2535 })
2536 .unwrap_or_default();
2537
2538 let opts = ChartOptions {
2539 chart_type: ChartType::parse(¶ms.chart_type)?,
2540 x_column: params.x.clone(),
2541 y_column: params.y.clone(),
2542 series_column: params.series.clone(),
2543 title: params.title.clone(),
2544 format,
2545 width: params.width.unwrap_or(800).clamp(200, 4096),
2546 height: params.height.unwrap_or(480).clamp(150, 4096),
2547 bins: params.bins.unwrap_or(20).clamp(1, 500),
2548 x_as_category: params.x_as_category,
2549 x_range: params.x_range,
2550 y_range: params.y_range,
2551 color_map,
2552 label_points: params.label_points.unwrap_or(false),
2553 };
2554
2555 let chart = render_chart(&rows, &opts)?;
2556
2557 let disposition = crate::chart::resolve_chart_disposition(
2561 params.inline.unwrap_or(true),
2562 params.output_path.as_deref(),
2563 opts.format,
2564 );
2565 let overwrite = params.overwrite.unwrap_or(true);
2566 if let Some(path) = disposition.path() {
2567 crate::chart::write_chart_to_disk(path, &chart.bytes, overwrite)?;
2568 }
2569
2570 let elapsed = timer.elapsed_ms();
2571 Ok((chart, elapsed, opts, disposition))
2572 });
2573
2574 match result {
2575 Ok((chart, elapsed_ms, opts, disposition)) => {
2576 let format_str = match opts.format {
2577 ChartFormat::Png => "png",
2578 ChartFormat::Svg => "svg",
2579 };
2580 let wants_inline = disposition.wants_inline();
2581 let output_path_str = disposition.path().map(|p| p.to_string_lossy().into_owned());
2582
2583 let mut stats = serde_json::Map::new();
2584 stats.insert("operation".into(), json!("chart"));
2585 stats.insert("rows_plotted".into(), json!(chart.rows_plotted));
2586 stats.insert("elapsed_ms".into(), json!(elapsed_ms));
2587 stats.insert("format".into(), json!(format_str));
2588 stats.insert("bytes".into(), json!(chart.bytes.len()));
2589 stats.insert("width".into(), json!(opts.width));
2590 stats.insert("height".into(), json!(opts.height));
2591 stats.insert("inline".into(), json!(wants_inline));
2592 if let Some(p) = output_path_str {
2593 stats.insert("output_path".into(), json!(p));
2594 }
2595 let stats_text =
2596 serde_json::to_string_pretty(&Value::Object(stats)).unwrap_or_default();
2597
2598 let mut content = Vec::with_capacity(2);
2599 if wants_inline {
2600 let b64 = base64::engine::general_purpose::STANDARD.encode(&chart.bytes);
2601 content.push(Content::image(b64, chart.mime_type.to_string()));
2602 }
2603 content.push(Content::text(stats_text));
2604 Ok(CallToolResult::success(content))
2605 }
2606 Err(e) => Self::err_content(e),
2607 }
2608 }
2609
2610 #[tool(
2613 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."
2614 )]
2615 fn watch_directory(
2616 &self,
2617 Parameters(params): Parameters<WatchDirectoryParams>,
2618 ) -> Result<CallToolResult, rmcp::ErrorData> {
2619 if let Err(e) = self.check_writable("watch_directory") {
2620 return Self::err_content(e);
2621 }
2622 let canonical = match crate::attach::validate_input_path(¶ms.path, "watch directory") {
2623 Ok(p) => p,
2624 Err(e) => return Self::err_content(e),
2625 };
2626 match self.ensure_engine() {
2629 Ok(guard) => drop(guard),
2630 Err(e) => return Self::err_content(e),
2631 }
2632
2633 let target_db = match self.with_engine(|engine| {
2637 self.resolve_db(engine, params.database.as_deref(), params.persist, true)
2638 }) {
2639 Ok(v) => v,
2640 Err(e) => return Self::err_content(e),
2641 };
2642
2643 let path = canonical;
2644 let engine_handle = self.engine_handle();
2645 let attachments = self.attachments_handle();
2646 let registry = self.watchers_handle();
2647 let options = crate::watcher::WatchOptions {
2648 max_concurrent: params.max_concurrent.unwrap_or(0) as usize,
2649 };
2650 let result = crate::watcher::start_watching(
2651 engine_handle,
2652 attachments,
2653 registry,
2654 Some(self.subscriptions_handle()),
2655 path.clone(),
2656 params.table.clone(),
2657 target_db,
2658 options,
2659 );
2660 match result {
2661 Ok(stats) => {
2662 let body = json!({
2663 "directory": path.to_string_lossy(),
2664 "table": params.table,
2665 "status": "watching",
2666 "max_concurrent": stats.max_concurrent,
2667 "initial_sweep": {
2668 "files_ingested": stats.files_ingested,
2669 "files_failed": stats.files_failed,
2670 },
2671 });
2672 Self::ok_content(body)
2673 }
2674 Err(e) => Self::err_content(e),
2675 }
2676 }
2677
2678 #[tool(
2680 description = "Stop watching a directory previously registered with watch_directory. Pending .ready files are left in place."
2681 )]
2682 fn unwatch_directory(
2683 &self,
2684 Parameters(params): Parameters<UnwatchDirectoryParams>,
2685 ) -> Result<CallToolResult, rmcp::ErrorData> {
2686 let path = std::path::PathBuf::from(¶ms.path);
2687 let result = crate::watcher::stop_watching(&self.watchers_handle(), &path);
2688 match result {
2689 Ok(summary) => Self::ok_content(summary),
2690 Err(e) => Self::err_content(e),
2691 }
2692 }
2693
2694 #[tool(
2697 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."
2698 )]
2699 fn describe(
2700 &self,
2701 Parameters(params): Parameters<DescribeParams>,
2702 ) -> Result<CallToolResult, rmcp::ErrorData> {
2703 let result = self.with_engine(|engine| {
2704 let target_db = self.resolve_db(engine, params.database.as_deref(), None, false)?;
2705 match params.table.as_deref() {
2706 Some(name) => engine
2707 .describe_table_in(target_db.as_deref(), name)
2708 .map(|t| vec![t]),
2709 None => engine.describe_tables_in(target_db.as_deref()),
2710 }
2711 });
2712
2713 match result {
2714 Ok(tables) => Self::ok_content(json!({"tables": tables})),
2715 Err(e) => Self::err_content(e),
2716 }
2717 }
2718
2719 #[tool(
2724 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)."
2725 )]
2726 #[expect(
2727 clippy::unused_self,
2728 reason = "method retained on the type for API symmetry; implementation currently does not need state"
2729 )]
2730 fn inspect_file(
2731 &self,
2732 Parameters(params): Parameters<InspectFileParams>,
2733 ) -> Result<CallToolResult, rmcp::ErrorData> {
2734 if let Err(e) = crate::attach::validate_input_path(¶ms.path, "data file") {
2735 return Self::err_content(e);
2736 }
2737 let sample_rows = params.sample_rows.unwrap_or(5).clamp(1, 50) as usize;
2738 let result = if let Some(ref json_path) = params.json_extract_path {
2739 (|| -> Result<_, McpError> {
2740 let raw = std::fs::read_to_string(¶ms.path).map_err(|e| {
2741 McpError::new(
2742 ErrorCode::FileNotFound,
2743 format!("Cannot read file '{}': {e}", params.path),
2744 )
2745 })?;
2746 let file_size = std::fs::metadata(¶ms.path).map_or(0, |m| m.len());
2747 let extracted = crate::ingest::extract_json_path(&raw, json_path)?;
2748 crate::inspect::inspect_json_from_text(&extracted, file_size, sample_rows)
2749 })()
2750 } else {
2751 crate::inspect::inspect_source(¶ms.path, sample_rows)
2752 };
2753 match result {
2754 Ok(report) => Self::ok_content(report.to_json()),
2755 Err(e) => Self::err_content(e),
2756 }
2757 }
2758
2759 #[tool(
2762 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)."
2763 )]
2764 fn export(
2765 &self,
2766 Parameters(params): Parameters<ExportParams>,
2767 ) -> Result<CallToolResult, rmcp::ErrorData> {
2768 let result = self.with_engine(|engine| {
2769 crate::attach::validate_output_path(¶ms.path, "export")?;
2772 let format_options = match params.format_options.clone() {
2776 None => None,
2777 Some(Value::Object(m)) => Some(m),
2778 Some(other) => {
2779 return Err(McpError::new(
2780 ErrorCode::SchemaMismatch,
2781 format!("export: format_options must be a JSON object, got: {other}"),
2782 ));
2783 }
2784 };
2785 let target_db = self.resolve_db(engine, params.database.as_deref(), None, false)?;
2796 let (effective_sql, effective_table) = match (¶ms.sql, ¶ms.table, &target_db) {
2797 (None, Some(t), Some(db)) => {
2798 let esc_db = db.replace('"', "\"\"");
2799 let esc_tbl = t.replace('"', "\"\"");
2800 (
2801 Some(format!(
2802 "SELECT * FROM \"{esc_db}\".\"public\".\"{esc_tbl}\""
2803 )),
2804 None,
2805 )
2806 }
2807 _ => (params.sql.clone(), params.table.clone()),
2808 };
2809 let _search_guard = match (&effective_sql, &target_db, ¶ms.sql) {
2810 (Some(_), Some(alias), Some(_)) => Some(engine.scoped_search_path(alias)?),
2813 _ => None,
2814 };
2815 let opts = ExportOptions {
2816 sql: effective_sql,
2817 table: effective_table,
2818 path: params.path,
2819 format: params.format,
2820 overwrite: params.overwrite.unwrap_or(true),
2821 format_options,
2822 source_db: target_db.clone(),
2823 };
2824 let export_result = export_to_file(engine, &opts)?;
2825 Ok(json!({
2826 "output_path": export_result.stats.output_path,
2827 "rows": export_result.rows,
2828 "file_size_bytes": export_result.stats.file_size_bytes,
2829 "stats": export_result.stats.to_json(),
2830 }))
2831 });
2832
2833 match result {
2834 Ok(val) => Self::ok_content(val),
2835 Err(e) => Self::err_content(e),
2836 }
2837 }
2838
2839 #[tool(
2843 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."
2844 )]
2845 fn save_query(
2846 &self,
2847 Parameters(params): Parameters<SaveQueryParams>,
2848 ) -> Result<CallToolResult, rmcp::ErrorData> {
2849 if let Err(e) = self.check_writable("save_query") {
2850 return Self::err_content(e);
2851 }
2852 if !is_read_only_sql(¶ms.sql) {
2857 return Self::err_content(McpError::new(
2858 ErrorCode::SqlError,
2859 "save_query only accepts read-only SQL (SELECT / WITH / EXPLAIN / SHOW / VALUES). \
2860 Use the execute tool for DDL/DML, not save_query.",
2861 ));
2862 }
2863 if params.name.is_empty() {
2864 return Self::err_content(McpError::new(
2865 ErrorCode::SchemaMismatch,
2866 "Saved query name must not be empty.",
2867 ));
2868 }
2869 let query = SavedQuery {
2870 name: params.name.clone(),
2871 sql: params.sql,
2872 description: params.description,
2873 created_at: chrono::Utc::now(),
2874 };
2875 let store = Arc::clone(&self.saved_queries);
2876 let result = self.with_saved_query_store(|engine| store.save(engine, query.clone()));
2877 match result {
2878 Ok(()) => {
2879 self.notify_resource_list_changed();
2883 Self::ok_content(json!({
2884 "saved": true,
2885 "name": query.name,
2886 "resources": [
2887 format!("hyper://queries/{}/definition", query.name),
2888 format!("hyper://queries/{}/result", query.name),
2889 ],
2890 "created_at": query.created_at.to_rfc3339(),
2891 }))
2892 }
2893 Err(e) => Self::err_content(e),
2894 }
2895 }
2896
2897 #[tool(
2899 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)."
2900 )]
2901 fn delete_query(
2902 &self,
2903 Parameters(params): Parameters<DeleteQueryParams>,
2904 ) -> Result<CallToolResult, rmcp::ErrorData> {
2905 if let Err(e) = self.check_writable("delete_query") {
2906 return Self::err_content(e);
2907 }
2908 let store = Arc::clone(&self.saved_queries);
2909 let name = params.name.clone();
2910 let result = self.with_saved_query_store(|engine| store.delete(engine, &name));
2911 match result {
2912 Ok(deleted) => {
2913 if deleted {
2914 self.notify_resource_list_changed();
2919 self.subscriptions
2920 .notify_updated(&format!("hyper://queries/{name}/definition"));
2921 self.subscriptions
2922 .notify_updated(&format!("hyper://queries/{name}/result"));
2923 }
2924 Self::ok_content(json!({
2925 "deleted": deleted,
2926 "name": params.name,
2927 }))
2928 }
2929 Err(e) => Self::err_content(e),
2930 }
2931 }
2932
2933 #[tool(
2935 description = "Update prose metadata for a table in the `_table_catalog`: source_url, source_description, purpose, license, notes, data_url. Fields you omit stay unchanged; pass an explicit empty string (\"\") to clear a field. `data_url` is the machine-actionable download URL for the raw data file (distinct from `source_url`, which is a human-readable reference page). Mechanical fields (load_tool, load_params, loaded_at, last_refreshed_at, row_count) are managed by the server. Requires an existing catalog entry — load the table first (load_file / load_data / execute CREATE TABLE) so the stub row is created automatically. Use `database` to target the metadata for a table in a non-primary writable database; read-only attachments are rejected with a clear re-attach-with-writable message. Disabled in read-only mode."
2936 )]
2937 fn set_table_metadata(
2938 &self,
2939 Parameters(params): Parameters<SetTableMetadataParams>,
2940 ) -> Result<CallToolResult, rmcp::ErrorData> {
2941 if let Err(e) = self.check_writable("set_table_metadata") {
2942 return Self::err_content(e);
2943 }
2944 let fields = crate::table_catalog::MetadataFields {
2945 source_url: params.source_url,
2946 source_description: params.source_description,
2947 purpose: params.purpose,
2948 license: params.license,
2949 notes: params.notes,
2950 data_url: params.data_url,
2951 };
2952 let table_name = params.table.clone();
2953 let result = self.with_engine(|engine| {
2954 let target_db = self.resolve_db(engine, params.database.as_deref(), None, true)?;
2960 crate::table_catalog::set_metadata_in(
2961 engine,
2962 &table_name,
2963 &fields,
2964 target_db.as_deref(),
2965 )
2966 });
2967 match result {
2968 Ok(entry) => Self::ok_content(entry.to_json()),
2969 Err(e) => Self::err_content(e),
2970 }
2971 }
2972
2973 #[tool(
2977 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."
2978 )]
2979 fn status(&self) -> Result<CallToolResult, rmcp::ErrorData> {
2980 let Ok(guard) = self.engine.try_lock() else {
2993 return Self::ok_content(self.status_degraded());
2994 };
2995 let Some(engine) = guard.as_ref() else {
2996 return Self::ok_content(self.status_degraded());
2997 };
2998 self.ensure_catalog_ready(engine);
2999 let result = engine.status();
3000
3001 match result {
3002 Ok(mut val) => {
3003 if let Some(obj) = val.as_object_mut() {
3004 obj.insert("engine_busy".into(), json!(false));
3005 obj.insert("watchers".into(), self.watchers.to_json());
3006 obj.insert("read_only".into(), json!(self.read_only));
3007 let attachments: Vec<Value> = self
3008 .attachments
3009 .list()
3010 .iter()
3011 .map(super::attach::AttachedDb::to_json)
3012 .collect();
3013 obj.insert("attachments".into(), Value::Array(attachments));
3014 }
3015 Self::ok_content(val)
3016 }
3017 Err(e) => Self::err_content(e),
3018 }
3019 }
3020
3021 #[tool(
3026 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."
3027 )]
3028 #[expect(
3029 clippy::unused_self,
3030 reason = "the #[tool] macro dispatches on &self; signature must match the rest of the tool surface even though this tool is stateless"
3031 )]
3032 #[expect(
3033 clippy::unnecessary_wraps,
3034 reason = "uniform Result<CallToolResult, rmcp::ErrorData> across all tools so the #[tool_router] dispatcher has one signature shape"
3035 )]
3036 fn get_readme(&self) -> Result<CallToolResult, rmcp::ErrorData> {
3037 Ok(CallToolResult::success(vec![Content::text(
3038 crate::readme::README,
3039 )]))
3040 }
3041
3042 #[tool(
3045 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."
3046 )]
3047 fn attach_database(
3048 &self,
3049 Parameters(params): Parameters<AttachDatabaseParams>,
3050 ) -> Result<CallToolResult, rmcp::ErrorData> {
3051 let writable = params.writable.unwrap_or(false);
3052 if writable {
3053 if let Err(e) = self.check_writable("attach_database(writable)") {
3054 return Self::err_content(e);
3055 }
3056 }
3057 let on_missing = match attach::OnMissing::parse(params.on_missing.as_deref()) {
3058 Ok(v) => v,
3059 Err(e) => return Self::err_content(e),
3060 };
3061 if on_missing == attach::OnMissing::Create && !writable {
3062 return Self::err_content(McpError::new(
3063 ErrorCode::InvalidArgument,
3064 "on_missing='create' requires writable:true — an empty .hyper file that cannot be written to cannot be populated.",
3065 ));
3066 }
3067 let source = match params.kind.as_str() {
3068 "local_file" => {
3069 let Some(raw) = params.path.as_deref() else {
3070 return Self::err_content(McpError::new(
3071 ErrorCode::InvalidArgument,
3072 "kind='local_file' requires a 'path' argument",
3073 ));
3074 };
3075 let resolved = match on_missing {
3076 attach::OnMissing::Error => attach::validate_local_path(raw),
3077 attach::OnMissing::Create => attach::validate_local_path_for_create(raw),
3078 };
3079 match resolved {
3080 Ok(canonical) => AttachSource::LocalFile { path: canonical },
3081 Err(e) => return Self::err_content(e),
3082 }
3083 }
3084 other => {
3085 return Self::err_content(McpError::new(
3086 ErrorCode::InvalidArgument,
3087 format!(
3088 "Unsupported attach kind '{other}'. Only 'local_file' is supported today; \
3089 'tcp' (remote hyperd) and 'grpc' (Data 360) are planned."
3090 ),
3091 ));
3092 }
3093 };
3094 let req = AttachRequest {
3095 alias: params.alias.clone(),
3096 source,
3097 writable,
3098 on_missing,
3099 };
3100 let registry = self.attachments_handle();
3101 let alias_for_probe = req.alias.clone();
3102 let result = self.with_engine(|engine| {
3103 let entry = registry.attach(engine, req.clone())?;
3104 let tables_visible = probe_table_count(engine, &alias_for_probe);
3109 Ok(json!({
3110 "alias": entry.alias,
3111 "kind": entry.source.kind_str(),
3112 "source": entry.source.to_json(),
3113 "writable": entry.writable,
3114 "tables_visible": tables_visible,
3115 }))
3116 });
3117 match result {
3118 Ok(val) => Self::ok_content(val),
3119 Err(e) => Self::err_content(e),
3120 }
3121 }
3122
3123 #[tool(
3125 description = "Detach a database previously registered with attach_database. No-op when the alias is unknown. Returns {detached: true/false}."
3126 )]
3127 fn detach_database(
3128 &self,
3129 Parameters(params): Parameters<DetachDatabaseParams>,
3130 ) -> Result<CallToolResult, rmcp::ErrorData> {
3131 let alias = params.alias.to_ascii_lowercase();
3136 if let Ok(watchers) = self.watchers.watchers.lock() {
3142 let conflict = watchers
3143 .values()
3144 .find(|h| h.target_db.as_deref() == Some(alias.as_str()));
3145 if let Some(h) = conflict {
3146 return Self::err_content(McpError::new(
3147 ErrorCode::InvalidArgument,
3148 format!(
3149 "cannot detach '{alias}': an active watcher on directory '{}' targets it. \
3150 Call unwatch_directory(\"{}\") first.",
3151 h.directory.display(),
3152 h.directory.display()
3153 ),
3154 ));
3155 }
3156 }
3157 let registry = self.attachments_handle();
3158 let result = self.with_engine(|engine| {
3159 let outcome = registry.detach(engine, &alias)?;
3160 if outcome {
3161 engine.clear_catalog_cache_for(&alias);
3165 }
3166 Ok(outcome)
3167 });
3168 match result {
3169 Ok(detached) => {
3170 Self::ok_content(json!({ "alias": params.alias, "detached": detached }))
3171 }
3172 Err(e) => Self::err_content(e),
3173 }
3174 }
3175
3176 #[tool(
3186 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."
3187 )]
3188 fn list_attached_databases(&self) -> Result<CallToolResult, rmcp::ErrorData> {
3189 let result = self.with_engine(|engine| {
3190 let entries = self.attachments.list();
3191 let attachments: Vec<Value> = entries
3192 .iter()
3193 .map(|entry| {
3194 let mut obj = entry.to_json();
3195 let tables_visible = probe_table_count(engine, &entry.alias);
3196 if let Some(map) = obj.as_object_mut() {
3197 map.insert("tables_visible".into(), json!(tables_visible));
3198 }
3199 obj
3200 })
3201 .collect();
3202 Ok(json!({ "attachments": attachments }))
3203 });
3204 match result {
3205 Ok(val) => Self::ok_content(val),
3206 Err(e) => Self::err_content(e),
3207 }
3208 }
3209
3210 #[tool(
3215 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."
3216 )]
3217 fn copy_query(
3218 &self,
3219 Parameters(params): Parameters<CopyQueryParams>,
3220 ) -> Result<CallToolResult, rmcp::ErrorData> {
3221 if let Err(e) = self.check_writable("copy_query") {
3222 return Self::err_content(e);
3223 }
3224 let mode = match params.mode.as_str() {
3225 "create" | "append" | "replace" => params.mode.clone(),
3226 other => {
3227 return Self::err_content(McpError::new(
3228 ErrorCode::InvalidArgument,
3229 format!(
3230 "copy_query mode '{other}' is not supported. Use 'create', 'append', or 'replace'."
3231 ),
3232 ));
3233 }
3234 };
3235 if !is_read_only_sql(¶ms.sql) {
3236 return Self::err_content(McpError::new(
3237 ErrorCode::SqlError,
3238 "copy_query's `sql` must be a read-only statement (SELECT / WITH / VALUES). \
3239 Use the execute tool for raw DDL/DML.",
3240 ));
3241 }
3242 let target_db_owned = params
3254 .target_database
3255 .as_deref()
3256 .filter(|s| !s.eq_ignore_ascii_case(LOCAL_ALIAS))
3257 .map(str::to_ascii_lowercase);
3258 let target_db = target_db_owned.as_deref();
3259 if let Some(alias) = target_db {
3260 match self.attachments.get(alias) {
3261 None => {
3262 return Self::err_content(McpError::new(
3263 ErrorCode::InvalidArgument,
3264 format!(
3265 "target_database '{alias}' is not attached. Call attach_database first."
3266 ),
3267 ));
3268 }
3269 Some(entry) if !entry.writable => {
3270 return Self::err_content(McpError::new(
3271 ErrorCode::InvalidArgument,
3272 format!(
3273 "target_database '{alias}' was attached read-only. Re-attach with writable:true to use it as a copy target."
3274 ),
3275 ));
3276 }
3277 Some(_) => {}
3278 }
3279 }
3280
3281 let temp_specs = params.temp_attach.clone().unwrap_or_default();
3284 let prepared_temps = match prepare_temp_attachments(&temp_specs, self.is_read_only()) {
3285 Ok(v) => v,
3286 Err(e) => return Self::err_content(e),
3287 };
3288
3289 let target_table = params.target_table.clone();
3290 let sql_body = params.sql.clone();
3291 let load_params = serde_json::to_string(&json!({
3292 "mode": mode,
3293 "target_database": params.target_database,
3294 "target_table": target_table,
3295 "sql": Self::fmt_sql(&sql_body),
3296 }))
3297 .ok();
3298
3299 let registry = self.attachments_handle();
3300 let result = self.with_engine(|engine| {
3301 let mut temp_aliases: Vec<String> = Vec::new();
3303 for req in &prepared_temps {
3304 match registry.attach(engine, req.clone()) {
3305 Ok(entry) => temp_aliases.push(entry.alias),
3306 Err(e) => {
3307 for alias in &temp_aliases {
3309 let _ = registry.detach(engine, alias);
3310 }
3311 return Err(e);
3312 }
3313 }
3314 }
3315
3316 let copy_outcome = perform_copy(engine, &mode, target_db, &target_table, &sql_body);
3319
3320 for alias in &temp_aliases {
3324 if let Err(e) = registry.detach(engine, alias) {
3325 tracing::warn!(
3326 alias = %alias,
3327 err = %e.message,
3328 "failed to detach temp attachment after copy_query",
3329 );
3330 }
3331 }
3332
3333 if copy_outcome.is_ok() && target_db.is_none() {
3344 let row_count = copy_outcome
3345 .as_ref()
3346 .ok()
3347 .and_then(|v| v.get("row_count").and_then(serde_json::Value::as_i64));
3348 self.after_ingest_catalog_update(
3349 engine,
3350 &target_table,
3351 "copy_query",
3352 load_params.as_deref(),
3353 row_count,
3354 target_db,
3355 );
3356 }
3357
3358 copy_outcome
3359 });
3360
3361 match result {
3362 Ok(outcome) => {
3363 if target_db.is_none() {
3365 self.notify_table_changed(&target_table);
3366 }
3367 self.notify_workspace_changed();
3368 if mode != "append" {
3369 self.notify_resource_list_changed();
3372 }
3373 Self::ok_content(outcome)
3374 }
3375 Err(e) => Self::err_content(e),
3376 }
3377 }
3378}
3379
3380#[prompt_router]
3383impl HyperMcpServer {
3384 #[prompt(
3386 name = "analyze-table",
3387 description = "Deep analysis of a single table: schema, sample, column stats, data quality"
3388 )]
3389 pub async fn analyze_table(
3390 &self,
3391 Parameters(args): Parameters<AnalyzeTableArgs>,
3392 ) -> Vec<PromptMessage> {
3393 let context = self.build_analyze_context(&args.table);
3394 vec![
3395 PromptMessage::new_text(
3396 PromptMessageRole::User,
3397 format!(
3398 "Analyze the `{}` table thoroughly.\n\n{}\n\nPlease:\n\
3399 1. Describe each column (what it likely represents based on name and sample values)\n\
3400 2. Compute basic statistics using the query tool: min/max/avg for numeric columns, distinct count and top values for text columns\n\
3401 3. Flag any data quality issues: unexpected NULLs, suspicious outliers, inconsistent formats\n\
3402 4. Summarize your findings in plain English",
3403 args.table, context
3404 ),
3405 ),
3406 PromptMessage::new_text(
3407 PromptMessageRole::Assistant,
3408 format!(
3409 "I'll analyze the `{}` table systematically. Let me start by examining the schema and sample, then run targeted queries for statistics and data quality.",
3410 args.table
3411 ),
3412 ),
3413 ]
3414 }
3415
3416 #[prompt(
3418 name = "compare-tables",
3419 description = "Compare two tables: schema alignment, common keys, JOIN opportunities"
3420 )]
3421 pub async fn compare_tables(
3422 &self,
3423 Parameters(args): Parameters<CompareTablesArgs>,
3424 ) -> Vec<PromptMessage> {
3425 let ctx_a = self.build_brief_context(&args.table_a);
3426 let ctx_b = self.build_brief_context(&args.table_b);
3427 vec![
3428 PromptMessage::new_text(
3429 PromptMessageRole::User,
3430 format!(
3431 "Compare these two tables:\n\n## Table A: `{}`\n{}\n\n## Table B: `{}`\n{}\n\nPlease:\n\
3432 1. Identify columns that appear in both tables (by name or semantic match)\n\
3433 2. Suggest likely JOIN keys and the JOIN type (inner, left, etc.)\n\
3434 3. Highlight schema differences (column types, nullability)\n\
3435 4. Propose 3-5 analytical queries that combine both tables and explain what each reveals",
3436 args.table_a, ctx_a, args.table_b, ctx_b
3437 ),
3438 ),
3439 PromptMessage::new_text(
3440 PromptMessageRole::Assistant,
3441 format!(
3442 "I'll compare `{}` and `{}` systematically — schema alignment first, then join keys, then analytical opportunities.",
3443 args.table_a, args.table_b
3444 ),
3445 ),
3446 ]
3447 }
3448
3449 #[prompt(
3451 name = "data-quality",
3452 description = "Systematic data quality assessment: NULL rates, duplicates, low cardinality, outliers"
3453 )]
3454 pub async fn data_quality(
3455 &self,
3456 Parameters(args): Parameters<DataQualityArgs>,
3457 ) -> Vec<PromptMessage> {
3458 let context = self.build_brief_context(&args.table);
3459 vec![
3460 PromptMessage::new_text(
3461 PromptMessageRole::User,
3462 format!(
3463 "Run a data quality assessment on the `{}` table.\n\n{}\n\nPlease use the query tool to check:\n\
3464 1. NULL rate per column — run SELECT COUNT(*) FILTER (WHERE col IS NULL) / COUNT(*) for each column\n\
3465 2. Duplicate rows — compare COUNT(*) vs COUNT(DISTINCT *) or use GROUP BY\n\
3466 3. Low-cardinality columns — columns with suspiciously few distinct values\n\
3467 4. Numeric outliers — values more than 3 stddev from the mean\n\
3468 5. Date sanity — future dates or impossibly old dates in date/timestamp columns\n\n\
3469 Summarize findings with severity (critical / warning / info) and suggest remediation for each issue.",
3470 args.table, context
3471 ),
3472 ),
3473 PromptMessage::new_text(
3474 PromptMessageRole::Assistant,
3475 format!(
3476 "I'll perform a systematic data quality assessment on `{}`. Let me run targeted queries for each check category.",
3477 args.table
3478 ),
3479 ),
3480 ]
3481 }
3482
3483 #[prompt(
3485 name = "suggest-queries",
3486 description = "Suggest analytical SQL queries for a table, optionally guided by a goal"
3487 )]
3488 pub async fn suggest_queries(
3489 &self,
3490 Parameters(args): Parameters<SuggestQueriesArgs>,
3491 ) -> Vec<PromptMessage> {
3492 let context = self.build_analyze_context(&args.table);
3493 let goal_section = match args.goal.as_deref() {
3494 Some(g) if !g.is_empty() => format!("\n\nSpecific goal: {g}"),
3495 _ => String::new(),
3496 };
3497 vec![
3498 PromptMessage::new_text(
3499 PromptMessageRole::User,
3500 format!(
3501 "Given the `{}` table:\n\n{}{}\n\nSuggest 5 analytical SQL queries that would be useful for exploring this data. \
3502 For each query, provide:\n\
3503 - A descriptive title\n\
3504 - The exact SQL (valid for Hyper / PostgreSQL-compatible syntax)\n\
3505 - One sentence explaining what insight it reveals\n\n\
3506 Prefer queries that use aggregations, GROUP BY, window functions, or CTEs to demonstrate the power of SQL analytics.",
3507 args.table, context, goal_section
3508 ),
3509 ),
3510 PromptMessage::new_text(
3511 PromptMessageRole::Assistant,
3512 format!(
3513 "Based on the schema and sample of `{}`, here are 5 analytical queries.",
3514 args.table
3515 ),
3516 ),
3517 ]
3518 }
3519}
3520
3521#[derive(Debug, Clone)]
3530pub enum ResourceBody {
3531 Json(Value),
3533 Text {
3536 mime_type: String,
3538 content: String,
3540 },
3541}
3542
3543impl ResourceBody {
3544 #[must_use]
3546 pub fn mime_type(&self) -> &str {
3547 match self {
3548 ResourceBody::Json(_) => "application/json",
3549 ResourceBody::Text { mime_type, .. } => mime_type,
3550 }
3551 }
3552
3553 #[must_use]
3556 pub fn to_text(&self) -> String {
3557 match self {
3558 ResourceBody::Json(v) => {
3559 serde_json::to_string_pretty(v).unwrap_or_else(|_| v.to_string())
3560 }
3561 ResourceBody::Text { content, .. } => content.clone(),
3562 }
3563 }
3564
3565 #[must_use]
3568 pub fn as_json(&self) -> Option<&Value> {
3569 match self {
3570 ResourceBody::Json(v) => Some(v),
3571 ResourceBody::Text { .. } => None,
3572 }
3573 }
3574}
3575
3576impl HyperMcpServer {
3577 pub fn resource_body_for_uri(&self, uri: &str) -> Result<Option<ResourceBody>, McpError> {
3596 if uri == "hyper://workspace" {
3597 return self
3598 .with_engine(super::engine::Engine::status)
3599 .map(|v| Some(ResourceBody::Json(v)));
3600 }
3601 if uri == "hyper://tables" {
3602 return self
3603 .with_engine(|engine| {
3604 engine
3605 .describe_tables()
3606 .map(|tables| json!({ "tables": tables }))
3607 })
3608 .map(|v| Some(ResourceBody::Json(v)));
3609 }
3610 if uri == "hyper://readme" {
3611 return self.build_readme_body().map(Some);
3612 }
3613 if let Some(name) = uri
3614 .strip_prefix("hyper://tables/")
3615 .and_then(|rest| rest.strip_suffix("/schema"))
3616 {
3617 let name = name.to_string();
3618 return self
3619 .with_engine(|engine| {
3620 let tables = engine.describe_tables()?;
3621 tables
3622 .into_iter()
3623 .find(|t| t.get("name").and_then(|v| v.as_str()) == Some(name.as_str()))
3624 .ok_or_else(|| {
3625 McpError::new(
3626 ErrorCode::TableNotFound,
3627 format!("Table '{name}' does not exist"),
3628 )
3629 })
3630 })
3631 .map(|v| Some(ResourceBody::Json(v)));
3632 }
3633 if let Some(name) = uri
3634 .strip_prefix("hyper://tables/")
3635 .and_then(|rest| rest.strip_suffix("/sample"))
3636 {
3637 let name = name.to_string();
3638 return self
3639 .with_engine(|engine| engine.sample_table(&name, TABLE_SAMPLE_ROWS))
3640 .map(|v| Some(ResourceBody::Json(v)));
3641 }
3642 if let Some(name) = uri
3643 .strip_prefix("hyper://tables/")
3644 .and_then(|rest| rest.strip_suffix("/csv-sample"))
3645 {
3646 let name = name.to_string();
3647 return self.build_csv_sample_body(&name).map(Some);
3648 }
3649 if let Some(name) = uri
3650 .strip_prefix("hyper://queries/")
3651 .and_then(|rest| rest.strip_suffix("/definition"))
3652 {
3653 return self.build_saved_query_definition(name).map(Some);
3654 }
3655 if let Some(name) = uri
3656 .strip_prefix("hyper://queries/")
3657 .and_then(|rest| rest.strip_suffix("/result"))
3658 {
3659 return self.build_saved_query_result(name).map(Some);
3660 }
3661 Ok(None)
3662 }
3663
3664 fn build_saved_query_definition(&self, name: &str) -> Result<ResourceBody, McpError> {
3668 let store = Arc::clone(&self.saved_queries);
3669 let name = name.to_string();
3670 let query = self.with_saved_query_store(|engine| store.get(engine, &name))?;
3671 match query {
3672 Some(q) => Ok(ResourceBody::Json(q.to_json())),
3673 None => Err(McpError::new(
3674 ErrorCode::TableNotFound,
3675 format!("No saved query named '{name}'"),
3676 )),
3677 }
3678 }
3679
3680 fn build_saved_query_result(&self, name: &str) -> Result<ResourceBody, McpError> {
3685 let store = Arc::clone(&self.saved_queries);
3686 let name_owned = name.to_string();
3687 let query = self
3688 .with_saved_query_store(|engine| store.get(engine, &name_owned))?
3689 .ok_or_else(|| {
3690 McpError::new(
3691 ErrorCode::TableNotFound,
3692 format!("No saved query named '{name_owned}'"),
3693 )
3694 })?;
3695 let sql = query.sql.clone();
3696 let body = self.with_engine(|engine| {
3697 let timer = crate::stats::StatsTimer::start();
3698 let rows = engine.execute_query_to_json(&sql)?;
3699 let elapsed = timer.elapsed_ms();
3700 let result_size = serde_json::to_string(&rows).map_or(0, |s| s.len() as u64);
3701 let stats = crate::stats::QueryStats {
3702 operation: "saved_query".into(),
3703 rows_returned: rows.len() as u64,
3704 rows_scanned: 0,
3705 elapsed_ms: elapsed,
3706 result_size_bytes: result_size,
3707 tables_touched: vec![],
3708 };
3709 Ok(json!({
3710 "name": query.name,
3711 "sql": Self::fmt_sql(&query.sql),
3712 "result": rows,
3713 "stats": stats.to_json(),
3714 }))
3715 })?;
3716 Ok(ResourceBody::Json(body))
3717 }
3718
3719 #[must_use]
3726 pub fn list_resource_uris(&self) -> Vec<String> {
3727 let mut uris = vec![
3728 "hyper://workspace".to_string(),
3729 "hyper://tables".to_string(),
3730 "hyper://readme".to_string(),
3731 ];
3732 if let Ok(tables) = self.with_engine(super::engine::Engine::describe_tables) {
3733 for table in tables {
3737 if let Some(name) = table.get("name").and_then(|v| v.as_str()) {
3738 uris.push(format!("hyper://tables/{name}/schema"));
3739 uris.push(format!("hyper://tables/{name}/sample"));
3740 uris.push(format!("hyper://tables/{name}/csv-sample"));
3741 }
3742 }
3743 }
3744 let store = Arc::clone(&self.saved_queries);
3745 if let Ok(saved) = self.with_saved_query_store(|engine| store.list(engine)) {
3746 for q in saved {
3747 uris.push(format!("hyper://queries/{}/definition", q.name));
3748 uris.push(format!("hyper://queries/{}/result", q.name));
3749 }
3750 }
3751 uris
3752 }
3753
3754 fn build_readme_body(&self) -> Result<ResourceBody, McpError> {
3762 let status = self.with_engine(super::engine::Engine::status)?;
3763 let tables = self
3764 .with_engine(super::engine::Engine::describe_tables)
3765 .unwrap_or_default();
3766
3767 let workspace_mode = status
3768 .get("workspace_mode")
3769 .and_then(|v| v.as_str())
3770 .unwrap_or("unknown");
3771 let workspace_path = status
3772 .get("workspace_path")
3773 .and_then(|v| v.as_str())
3774 .unwrap_or("");
3775 let read_only = status
3776 .get("read_only")
3777 .and_then(serde_json::Value::as_bool)
3778 .unwrap_or(false);
3779 let table_count = tables.len();
3780
3781 let mut md = String::new();
3782 md.push_str("# HyperDB workspace\n\n");
3783 let _ = writeln!(
3784 md,
3785 "- Mode: **{workspace_mode}**{}\n",
3786 if read_only { " (read-only)" } else { "" }
3787 );
3788 if !workspace_path.is_empty() {
3789 let _ = writeln!(md, "- Path: `{workspace_path}`\n");
3790 }
3791 let _ = write!(md, "- Tables: **{table_count}**\n\n");
3792
3793 if tables.is_empty() {
3794 md.push_str(
3795 "_No tables loaded yet._ Use the `load_file` or `load_data` tools to \
3796 ingest CSV / JSON / Parquet / Arrow IPC data; call `inspect_file` \
3797 first if you're unsure of the schema.\n",
3798 );
3799 } else {
3800 md.push_str("## Tables\n\n");
3801 md.push_str("| Table | Rows | Columns |\n");
3802 md.push_str("|---|---:|---|\n");
3803 for t in &tables {
3804 let name = t.get("name").and_then(|v| v.as_str()).unwrap_or("?");
3805 let rows = t
3806 .get("row_count")
3807 .and_then(serde_json::Value::as_i64)
3808 .unwrap_or(0);
3809 let cols: Vec<String> = t
3810 .get("columns")
3811 .and_then(|v| v.as_array())
3812 .map(|arr| {
3813 arr.iter()
3814 .filter_map(|c| {
3815 let n = c.get("name")?.as_str()?;
3816 let ty = c.get("type")?.as_str()?;
3817 Some(format!("`{n}` {ty}"))
3818 })
3819 .collect()
3820 })
3821 .unwrap_or_default();
3822 let _ = writeln!(md, "| `{name}` | {rows} | {} |\n", cols.join(", "));
3823 }
3824 md.push('\n');
3825 md.push_str("## Related resources\n\n");
3826 for t in &tables {
3827 if let Some(name) = t.get("name").and_then(|v| v.as_str()) {
3828 let _ = write!(md, "- `hyper://tables/{name}/schema` — JSON schema and row count\n\
3829 - `hyper://tables/{name}/sample` — first {TABLE_SAMPLE_ROWS} rows as JSON\n\
3830 - `hyper://tables/{name}/csv-sample` — first {TABLE_CSV_SAMPLE_ROWS} rows as CSV\n");
3831 }
3832 }
3833 md.push('\n');
3834 }
3835
3836 md.push_str(
3837 "## Tool hints\n\n\
3838 - `query(sql)` — read-only SQL (SELECT / WITH / EXPLAIN / SHOW / VALUES).\n\
3839 - `execute(sql)` — DDL/DML (disabled in read-only mode).\n\
3840 - `sample(table, n)` — configurable row sample; the fixed-size\n \
3841 `hyper://tables/{name}/sample` resource uses n=5.\n\
3842 - `inspect_file(path)` — dry-run schema inference before loading.\n\
3843 - `chart(sql, chart_type, ...)` — render a PNG/SVG from a query.\n\
3844 - `export(sql|table, path, format)` — write to CSV / Parquet / Arrow IPC / .hyper.\n",
3845 );
3846
3847 Ok(ResourceBody::Text {
3848 mime_type: "text/markdown".into(),
3849 content: md,
3850 })
3851 }
3852
3853 fn build_csv_sample_body(&self, table: &str) -> Result<ResourceBody, McpError> {
3857 let sample =
3858 self.with_engine(|engine| engine.sample_table(table, TABLE_CSV_SAMPLE_ROWS))?;
3859
3860 let header: Vec<String> = sample
3864 .get("schema")
3865 .and_then(|v| v.as_array())
3866 .map(|cols| {
3867 cols.iter()
3868 .filter_map(|c| c.get("name").and_then(|n| n.as_str()).map(String::from))
3869 .collect()
3870 })
3871 .filter(|v: &Vec<String>| !v.is_empty())
3872 .or_else(|| {
3873 sample
3874 .get("rows")
3875 .and_then(|v| v.as_array())
3876 .and_then(|rows| rows.first())
3877 .and_then(|r| r.as_object())
3878 .map(|o| o.keys().cloned().collect())
3879 })
3880 .unwrap_or_default();
3881
3882 let mut wtr = csv::Writer::from_writer(Vec::<u8>::new());
3883 if !header.is_empty() {
3884 wtr.write_record(&header).map_err(|e| {
3885 McpError::new(
3886 ErrorCode::InternalError,
3887 format!("Failed to write CSV header: {e}"),
3888 )
3889 })?;
3890 }
3891 if let Some(rows) = sample.get("rows").and_then(|v| v.as_array()) {
3892 for row in rows {
3893 let record: Vec<String> = header
3894 .iter()
3895 .map(|col| row.get(col).map(value_to_csv_cell).unwrap_or_default())
3896 .collect();
3897 wtr.write_record(&record).map_err(|e| {
3898 McpError::new(
3899 ErrorCode::InternalError,
3900 format!("Failed to write CSV row: {e}"),
3901 )
3902 })?;
3903 }
3904 }
3905 let bytes = wtr.into_inner().map_err(|e| {
3906 McpError::new(
3907 ErrorCode::InternalError,
3908 format!("Failed to finalize CSV: {e}"),
3909 )
3910 })?;
3911 let content = String::from_utf8(bytes).map_err(|e| {
3912 McpError::new(
3913 ErrorCode::InternalError,
3914 format!("CSV produced invalid UTF-8: {e}"),
3915 )
3916 })?;
3917
3918 Ok(ResourceBody::Text {
3919 mime_type: "text/csv".into(),
3920 content,
3921 })
3922 }
3923
3924 fn build_analyze_context(&self, table: &str) -> String {
3927 match self.with_engine(|engine| engine.sample_table(table, 10)) {
3928 Ok(sample) => format!(
3929 "Schema and sample:\n```json\n{}\n```",
3930 serde_json::to_string_pretty(&sample).unwrap_or_else(|_| sample.to_string())
3931 ),
3932 Err(e) => format!("(Could not load table context: {e})"),
3933 }
3934 }
3935
3936 fn build_brief_context(&self, table: &str) -> String {
3938 match self.with_engine(|engine| engine.sample_table(table, 5)) {
3939 Ok(sample) => format!(
3940 "```json\n{}\n```",
3941 serde_json::to_string_pretty(&sample).unwrap_or_else(|_| sample.to_string())
3942 ),
3943 Err(e) => format!("(Could not load table context: {e})"),
3944 }
3945 }
3946}
3947
3948#[tool_handler]
3951#[prompt_handler]
3952impl ServerHandler for HyperMcpServer {
3953 fn get_info(&self) -> ServerInfo {
3954 let sql_dialect = "\n\
3955\n\
3956SQL DIALECT — Salesforce Data Cloud SQL (PostgreSQL-compatible with extensions).\n\
3957Key differences from standard PostgreSQL an LLM should know:\n\
3958\n\
3959TYPES\n\
3960- Supported: SMALLINT, INTEGER/INT, BIGINT, REAL/FLOAT4, DOUBLE PRECISION/FLOAT8,\n\
3961 NUMERIC(p,s)/DECIMAL(p,s), BOOLEAN, TEXT, CHAR(n), VARCHAR(n), BYTES,\n\
3962 DATE, TIME, TIMESTAMP, TIMESTAMPTZ, INTERVAL, and arrays of any atomic type\n\
3963- NUMERIC precision > 18 requires .hyper file format version 3 (default in this MCP)\n\
3964- No SERIAL / BIGSERIAL / UUID / JSON / JSONB / geometry types\n\
3965\n\
3966SELECT / QUERY\n\
3967- LIMIT / OFFSET work as in PostgreSQL; TOP N is also accepted\n\
3968- LATERAL is optional: subqueries in FROM always see preceding FROM items implicitly\n\
3969- DISTINCT ON (expr, ...) is supported\n\
3970- FROM clause is optional (can evaluate expressions without a table)\n\
3971- Function calls may appear directly in the FROM list\n\
3972- information_schema and pg_catalog do NOT exist; use the describe/sample tools\n\
3973\n\
3974GROUP BY / AGGREGATION\n\
3975- GROUPING SETS, ROLLUP, CUBE all supported\n\
3976- GROUP BY DISTINCT removes duplicate grouping sets before processing\n\
3977- FILTER (WHERE ...) clause supported on aggregate calls\n\
3978- Ordered-set aggregates: MODE(), PERCENTILE_CONT(), PERCENTILE_DISC() with WITHIN GROUP (ORDER BY ...)\n\
3979- APPROX_COUNT_DISTINCT() for fast approximate cardinality\n\
3980- GROUPING() function identifies which columns are aggregated in GROUPING SETS\n\
3981\n\
3982WINDOW FUNCTIONS\n\
3983- Standard: row_number, rank, dense_rank, percent_rank, cume_dist, ntile, lag, lead,\n\
3984 first_value, last_value, nth_value\n\
3985- Hyper extension: modified_rank() — like rank() but assigns the LOWEST rank on ties\n\
3986- IGNORE NULLS / RESPECT NULLS supported on last_value only\n\
3987- nth_value supports FROM FIRST / FROM LAST\n\
3988- Frame modes: ROWS, RANGE, GROUPS; EXCLUDE CURRENT ROW / GROUP / TIES / NO OTHERS\n\
3989- Window-specific functions do NOT support DISTINCT or ORDER BY in their argument list\n\
3990\n\
3991SET-RETURNING FUNCTIONS (usable in FROM)\n\
3992- unnest(array) — expands an array to rows; supports WITH ORDINALITY\n\
3993- generate_series(start, stop [, step]) — numeric and datetime variants\n\
3994- external(path, format => '...') — reads Parquet, CSV, Iceberg etc. directly from files\n\
3995\n\
3996SET OPERATORS\n\
3997- UNION, INTERSECT, EXCEPT all supported; INTERSECT binds tighter than UNION/EXCEPT\n\
3998- ORDER BY and LIMIT/OFFSET can appear on parenthesized sub-expressions or the final result\n\
3999\n\
4000CTEs\n\
4001- WITH and WITH RECURSIVE both supported\n\
4002- CTEs evaluate once per query execution even if referenced multiple times\n\
4003\n\
4004IDENTIFIERS\n\
4005- Unquoted identifiers are folded to lowercase; double-quote to preserve case or use special chars\n\
4006- Quote names containing uppercase letters, digits at the start, or special characters\n\
4007\n\
4008NOT AVAILABLE IN HYPER (Data 360 / Data Cloud-only features)\n\
4009- AI functions: AI_CLASSIFY, AI_SENTIMENT, and other Data Cloud AI scalar functions\n\
4010- Data Cloud federation / streaming-specific functions\n\
4011\n\
4012Full SQL reference: https://developer.salesforce.com/docs/data/data-cloud-query-guide/references/dc-sql-reference";
4013
4014 let header = if self.read_only {
4015 "HyperDB MCP (read-only): SQL analytics for LLM workflows. Query existing tables, \
4016 sample data, export results. Mutating operations are disabled. \
4017 Call get_readme for a concise tool index, parameter rules, and usage examples."
4018 } else {
4019 "HyperDB MCP: instant SQL analytics for LLM workflows. Load data (CSV, JSON, Parquet, \
4020 Arrow IPC, Apache Iceberg), query with SQL, export results (Parquet, Iceberg, Arrow IPC, \
4021 CSV, Hyper). Use query for SELECT and execute for DDL/DML. \
4022 Call get_readme for a concise tool index, parameter rules, and usage examples."
4023 };
4024 let instructions = format!("{header}{sql_dialect}");
4025 let mut server_info = Implementation::default();
4026 server_info.name = "HyperDB".into();
4027 server_info.title = Some("HyperDB — Hyper SQL Analytics".into());
4028 server_info.version = env!("CARGO_PKG_VERSION").into();
4029 server_info.description = Some(
4030 "MCP server for Tableau Hyper: instant SQL analytics over \
4031 CSV, JSON, Parquet, Arrow IPC, and Apache Iceberg with schema inference, \
4032 partial schema overrides, full-file numeric widening, and \
4033 dry-run file inspection. SQL dialect is PostgreSQL-compatible with \
4034 extensions (Salesforce Data Cloud SQL). Full SQL reference: \
4035 https://developer.salesforce.com/docs/data/data-cloud-query-guide/references/dc-sql-reference/data-cloud-sql-context.html"
4036 .into(),
4037 );
4038
4039 let mut info = ServerInfo::default();
4040 info.instructions = Some(instructions);
4041 info.server_info = server_info;
4042 info.capabilities = ServerCapabilities::builder()
4043 .enable_tools()
4044 .enable_prompts()
4045 .enable_resources()
4046 .enable_resources_subscribe()
4051 .enable_resources_list_changed()
4052 .build();
4053 info
4054 }
4055
4056 async fn initialize(
4057 &self,
4058 request: InitializeRequestParams,
4059 context: RequestContext<RoleServer>,
4060 ) -> Result<InitializeResult, rmcp::ErrorData> {
4061 let name = &request.client_info.name;
4062 let version = &request.client_info.version;
4063 let label = if version.is_empty() {
4064 name.clone()
4065 } else {
4066 format!("{name} {version}")
4067 };
4068 if let Ok(mut guard) = self.client_name.lock() {
4069 *guard = Some(label);
4070 }
4071 context.peer.set_peer_info(request);
4072 Ok(self.get_info())
4073 }
4074
4075 async fn subscribe(
4084 &self,
4085 request: SubscribeRequestParams,
4086 context: RequestContext<RoleServer>,
4087 ) -> Result<(), rmcp::ErrorData> {
4088 self.subscriptions.subscribe(&request.uri, context.peer);
4089 Ok(())
4090 }
4091
4092 async fn unsubscribe(
4097 &self,
4098 request: UnsubscribeRequestParams,
4099 context: RequestContext<RoleServer>,
4100 ) -> Result<(), rmcp::ErrorData> {
4101 self.subscriptions.unsubscribe(&request.uri, &context.peer);
4102 Ok(())
4103 }
4104
4105 async fn list_resources(
4111 &self,
4112 _request: Option<PaginatedRequestParams>,
4113 _context: RequestContext<RoleServer>,
4114 ) -> Result<ListResourcesResult, rmcp::ErrorData> {
4115 let mut resources = vec![
4116 RawResource {
4117 uri: "hyper://workspace".into(),
4118 name: "Workspace Info".into(),
4119 title: Some("Hyper Workspace".into()),
4120 description: Some("Workspace mode, table count, total rows, disk usage".into()),
4121 mime_type: Some("application/json".into()),
4122 size: None,
4123 icons: None,
4124 meta: None,
4125 }
4126 .no_annotation(),
4127 RawResource {
4128 uri: "hyper://tables".into(),
4129 name: "All Tables".into(),
4130 title: Some("All Tables".into()),
4131 description: Some("List of all tables with column schemas and row counts".into()),
4132 mime_type: Some("application/json".into()),
4133 size: None,
4134 icons: None,
4135 meta: None,
4136 }
4137 .no_annotation(),
4138 RawResource {
4139 uri: "hyper://readme".into(),
4140 name: "Workspace Readme".into(),
4141 title: Some("HyperDB workspace readme".into()),
4142 description: Some(
4143 "Markdown overview of the workspace: tables, row counts, related \
4144 resources, and tool hints for LLMs orienting themselves."
4145 .into(),
4146 ),
4147 mime_type: Some("text/markdown".into()),
4148 size: None,
4149 icons: None,
4150 meta: None,
4151 }
4152 .no_annotation(),
4153 ];
4154
4155 if let Ok(tables) = self.with_engine(super::engine::Engine::describe_tables) {
4156 for table in tables {
4160 if let Some(name) = table.get("name").and_then(|v| v.as_str()) {
4161 let row_count = table
4162 .get("row_count")
4163 .and_then(serde_json::Value::as_i64)
4164 .unwrap_or(0);
4165 resources.push(
4166 RawResource {
4167 uri: format!("hyper://tables/{name}/schema"),
4168 name: format!("Schema of {name}"),
4169 title: Some(format!("{name} schema")),
4170 description: Some(format!(
4171 "Column schema and row count ({row_count} rows) for table '{name}'"
4172 )),
4173 mime_type: Some("application/json".into()),
4174 size: None,
4175 icons: None,
4176 meta: None,
4177 }
4178 .no_annotation(),
4179 );
4180 resources.push(
4181 RawResource {
4182 uri: format!("hyper://tables/{name}/sample"),
4183 name: format!("Sample of {name}"),
4184 title: Some(format!("{name} sample (JSON)")),
4185 description: Some(format!(
4186 "First {TABLE_SAMPLE_ROWS} rows of '{name}' as JSON, with schema"
4187 )),
4188 mime_type: Some("application/json".into()),
4189 size: None,
4190 icons: None,
4191 meta: None,
4192 }
4193 .no_annotation(),
4194 );
4195 resources.push(
4196 RawResource {
4197 uri: format!("hyper://tables/{name}/csv-sample"),
4198 name: format!("CSV sample of {name}"),
4199 title: Some(format!("{name} sample (CSV)")),
4200 description: Some(format!(
4201 "First {TABLE_CSV_SAMPLE_ROWS} rows of '{name}' as CSV"
4202 )),
4203 mime_type: Some("text/csv".into()),
4204 size: None,
4205 icons: None,
4206 meta: None,
4207 }
4208 .no_annotation(),
4209 );
4210 }
4211 }
4212 }
4213
4214 let store = Arc::clone(&self.saved_queries);
4215 if let Ok(saved) = self.with_saved_query_store(|engine| store.list(engine)) {
4216 for q in saved {
4217 let desc = q
4218 .description
4219 .clone()
4220 .unwrap_or_else(|| format!("Saved read-only SQL query '{}'", q.name));
4221 resources.push(
4222 RawResource {
4223 uri: format!("hyper://queries/{}/definition", q.name),
4224 name: format!("Query: {}", q.name),
4225 title: Some(format!("{} (definition)", q.name)),
4226 description: Some(format!("SQL + metadata for saved query '{}'", q.name)),
4227 mime_type: Some("application/json".into()),
4228 size: None,
4229 icons: None,
4230 meta: None,
4231 }
4232 .no_annotation(),
4233 );
4234 resources.push(
4235 RawResource {
4236 uri: format!("hyper://queries/{}/result", q.name),
4237 name: format!("Result: {}", q.name),
4238 title: Some(format!("{} (result)", q.name)),
4239 description: Some(format!("{desc} — re-runs on every read")),
4240 mime_type: Some("application/json".into()),
4241 size: None,
4242 icons: None,
4243 meta: None,
4244 }
4245 .no_annotation(),
4246 );
4247 }
4248 }
4249
4250 Ok(ListResourcesResult {
4251 resources,
4252 next_cursor: None,
4253 meta: None,
4254 })
4255 }
4256
4257 async fn list_resource_templates(
4260 &self,
4261 _request: Option<PaginatedRequestParams>,
4262 _context: RequestContext<RoleServer>,
4263 ) -> Result<ListResourceTemplatesResult, rmcp::ErrorData> {
4264 let templates = vec![
4265 RawResourceTemplate {
4266 uri_template: "hyper://tables/{name}/schema".into(),
4267 name: "Table Schema".into(),
4268 title: Some("Table Schema".into()),
4269 description: Some(
4270 "Column schema, types, nullability, and row count for a named table".into(),
4271 ),
4272 mime_type: Some("application/json".into()),
4273 icons: None,
4274 }
4275 .no_annotation(),
4276 RawResourceTemplate {
4277 uri_template: "hyper://tables/{name}/sample".into(),
4278 name: "Table Sample (JSON)".into(),
4279 title: Some("Table Sample".into()),
4280 description: Some(
4281 "First few rows of a named table as JSON, with schema. For a \
4282 configurable row count use the `sample` tool instead."
4283 .into(),
4284 ),
4285 mime_type: Some("application/json".into()),
4286 icons: None,
4287 }
4288 .no_annotation(),
4289 RawResourceTemplate {
4290 uri_template: "hyper://tables/{name}/csv-sample".into(),
4291 name: "Table Sample (CSV)".into(),
4292 title: Some("Table Sample (CSV)".into()),
4293 description: Some(
4294 "First few rows of a named table as CSV, header-first, for \
4295 spreadsheet and Pandas consumers."
4296 .into(),
4297 ),
4298 mime_type: Some("text/csv".into()),
4299 icons: None,
4300 }
4301 .no_annotation(),
4302 RawResourceTemplate {
4303 uri_template: "hyper://queries/{name}/definition".into(),
4304 name: "Saved Query Definition".into(),
4305 title: Some("Saved Query Definition".into()),
4306 description: Some(
4307 "Stored SQL plus metadata (description, created_at) for a saved \
4308 query registered via the `save_query` tool."
4309 .into(),
4310 ),
4311 mime_type: Some("application/json".into()),
4312 icons: None,
4313 }
4314 .no_annotation(),
4315 RawResourceTemplate {
4316 uri_template: "hyper://queries/{name}/result".into(),
4317 name: "Saved Query Result".into(),
4318 title: Some("Saved Query Result".into()),
4319 description: Some(
4320 "Live result of a saved query. The stored SQL re-runs on every \
4321 resource read — no caching, always fresh."
4322 .into(),
4323 ),
4324 mime_type: Some("application/json".into()),
4325 icons: None,
4326 }
4327 .no_annotation(),
4328 ];
4329 Ok(ListResourceTemplatesResult {
4330 resource_templates: templates,
4331 next_cursor: None,
4332 meta: None,
4333 })
4334 }
4335
4336 async fn read_resource(
4341 &self,
4342 request: ReadResourceRequestParams,
4343 _context: RequestContext<RoleServer>,
4344 ) -> Result<ReadResourceResult, rmcp::ErrorData> {
4345 let uri = &request.uri;
4346 let (mime_type, text) = match self.resource_body_for_uri(uri) {
4347 Ok(Some(body)) => (body.mime_type().to_string(), body.to_text()),
4348 Ok(None) => {
4349 return Err(rmcp::ErrorData::invalid_params(
4350 format!("Unknown resource URI: {uri}"),
4351 None,
4352 ));
4353 }
4354 Err(e) => {
4355 let err_val = serde_json::to_value(&e).unwrap_or(Value::String(e.to_string()));
4358 let text =
4359 serde_json::to_string_pretty(&json!({ "error": err_val })).unwrap_or_default();
4360 ("application/json".into(), text)
4361 }
4362 };
4363
4364 Ok(ReadResourceResult::new(vec![
4365 ResourceContents::TextResourceContents {
4366 uri: uri.clone(),
4367 mime_type: Some(mime_type),
4368 text,
4369 meta: None,
4370 },
4371 ]))
4372 }
4373}
4374
4375fn validate_execute_batch(stmts: &[String]) -> Result<(), McpError> {
4399 use crate::engine::strip_leading_sql_comments;
4400
4401 if stmts.is_empty() {
4402 return Err(McpError::new(
4403 ErrorCode::InvalidArgument,
4404 "`sql` must be a non-empty array of SQL statements.",
4405 )
4406 .with_suggestion("Pass at least one statement: `sql: [\"INSERT INTO t VALUES (1)\"]`."));
4407 }
4408
4409 let mut has_schema_change = false;
4410 let mut has_data_mutation = false;
4411
4412 for (idx, stmt) in stmts.iter().enumerate() {
4413 if strip_leading_sql_comments(stmt).trim().is_empty() {
4414 return Err(McpError::new(
4415 ErrorCode::InvalidArgument,
4416 format!("`sql[{idx}]` is empty or contains only whitespace/comments."),
4417 )
4418 .with_suggestion("Remove the empty element or replace it with a real statement."));
4419 }
4420 match classify_statement(stmt) {
4429 StatementKind::ReadOnly => {
4430 return Err(McpError::new(
4431 ErrorCode::SqlError,
4432 format!(
4433 "`sql[{idx}]` is a read-only statement; the `execute` tool is for DDL/DML."
4434 ),
4435 )
4436 .with_suggestion(
4437 "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 …`).",
4438 ));
4439 }
4440 StatementKind::TransactionControl => {
4441 return Err(McpError::new(
4449 ErrorCode::InvalidArgument,
4450 format!(
4451 "`sql[{idx}]` is a transaction-control statement (BEGIN / COMMIT / ROLLBACK / SAVEPOINT); these are not allowed in `execute` batches."
4452 ),
4453 )
4454 .with_suggestion(
4455 "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.",
4456 ));
4457 }
4458 StatementKind::Ddl => has_schema_change = true,
4459 StatementKind::Dml => has_data_mutation = true,
4460 StatementKind::Other => {}
4461 }
4462 }
4463
4464 if has_schema_change && has_data_mutation {
4465 return Err(McpError::new(
4466 ErrorCode::InvalidArgument,
4467 "Cannot mix DDL (CREATE/DROP/ALTER/TRUNCATE/RENAME) with DML (INSERT/UPDATE/DELETE/COPY/MERGE) in one `execute` batch.",
4468 )
4469 .with_suggestion(
4470 "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.",
4471 ));
4472 }
4473
4474 if has_schema_change && stmts.len() > 1 {
4475 return Err(McpError::new(
4476 ErrorCode::InvalidArgument,
4477 "Multi-statement DDL batches are not supported.",
4478 )
4479 .with_suggestion(
4480 "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.",
4481 ));
4482 }
4483
4484 Ok(())
4485}
4486
4487fn value_to_csv_cell(v: &Value) -> String {
4493 match v {
4494 Value::Null => String::new(),
4495 Value::Bool(b) => b.to_string(),
4496 Value::Number(n) => n.to_string(),
4497 Value::String(s) => s.clone(),
4498 _ => v.to_string(),
4499 }
4500}
4501
4502fn detect_format(data: &str) -> String {
4505 let trimmed = data.trim_start();
4506 if trimmed.starts_with('[') || trimmed.starts_with('{') {
4507 "json".into()
4508 } else {
4509 "csv".into()
4510 }
4511}
4512
4513fn rand_suffix() -> String {
4517 use std::time::{SystemTime, UNIX_EPOCH};
4518 let t = SystemTime::now()
4519 .duration_since(UNIX_EPOCH)
4520 .unwrap_or_default();
4521 format!("{}", t.as_nanos() % 1_000_000_000)
4522}
4523
4524fn qualified_name(engine: &Engine, db: Option<&str>, table: &str) -> String {
4535 let alias = db.map_or_else(|| engine.primary_db_name(), str::to_string);
4536 let escaped_alias = alias.replace('"', "\"\"");
4537 let escaped_table = table.replace('"', "\"\"");
4538 format!("\"{escaped_alias}\".\"public\".\"{escaped_table}\"")
4539}
4540
4541fn target_exists(engine: &Engine, db: Option<&str>, table: &str) -> Result<bool, McpError> {
4546 let sql = format!(
4547 "SELECT 1 FROM {} LIMIT 0",
4548 qualified_name(engine, db, table)
4549 );
4550 match engine.execute_query_to_json(&sql) {
4551 Ok(_) => Ok(true),
4552 Err(e) => {
4553 let m = e.message.to_lowercase();
4554 let missing = m.contains("does not exist")
4555 || m.contains("undefined table")
4556 || e.message.contains("42P01");
4557 if missing {
4558 Ok(false)
4559 } else {
4560 Err(e)
4561 }
4562 }
4563 }
4564}
4565
4566fn count_rows(engine: &Engine, db: Option<&str>, table: &str) -> i64 {
4571 let sql = format!(
4572 "SELECT COUNT(*) AS cnt FROM {}",
4573 qualified_name(engine, db, table)
4574 );
4575 engine
4576 .execute_query_to_json(&sql)
4577 .ok()
4578 .and_then(|rows| {
4579 rows.first()
4580 .and_then(|r| r.get("cnt").and_then(serde_json::Value::as_i64))
4581 })
4582 .unwrap_or(0)
4583}
4584
4585fn probe_table_count(engine: &Engine, alias: &str) -> Value {
4589 let escaped_alias = alias.replace('"', "\"\"");
4590 let sql = format!(
4591 "SELECT COUNT(*) AS cnt FROM \"{escaped_alias}\".pg_catalog.pg_tables WHERE schemaname = 'public'"
4592 );
4593 match engine.execute_query_to_json(&sql) {
4594 Ok(rows) => rows
4595 .first()
4596 .and_then(|r| r.get("cnt").and_then(serde_json::Value::as_i64))
4597 .map_or(Value::Null, |n| json!(n)),
4598 Err(_) => Value::Null,
4599 }
4600}
4601
4602fn prepare_temp_attachments(
4606 specs: &[AttachSpec],
4607 read_only: bool,
4608) -> Result<Vec<AttachRequest>, McpError> {
4609 let mut out = Vec::with_capacity(specs.len());
4610 for spec in specs {
4611 let writable = spec.writable.unwrap_or(false);
4612 if writable && read_only {
4613 return Err(McpError::new(
4614 ErrorCode::ReadOnlyViolation,
4615 format!(
4616 "temp_attach for alias '{}' requested writable:true but the server is --read-only",
4617 spec.alias
4618 ),
4619 ));
4620 }
4621 let on_missing = attach::OnMissing::parse(spec.on_missing.as_deref())?;
4622 if on_missing == attach::OnMissing::Create && !writable {
4623 return Err(McpError::new(
4624 ErrorCode::InvalidArgument,
4625 format!(
4626 "temp_attach alias '{}' has on_missing='create' but writable is not true — \
4627 an empty .hyper file that cannot be written to cannot be populated.",
4628 spec.alias
4629 ),
4630 ));
4631 }
4632 let source = match spec.kind.as_str() {
4633 "local_file" => {
4634 let Some(raw) = spec.path.as_deref() else {
4635 return Err(McpError::new(
4636 ErrorCode::InvalidArgument,
4637 format!("temp_attach alias '{}' requires a 'path'", spec.alias),
4638 ));
4639 };
4640 let resolved = match on_missing {
4641 attach::OnMissing::Error => attach::validate_local_path(raw)?,
4642 attach::OnMissing::Create => attach::validate_local_path_for_create(raw)?,
4643 };
4644 AttachSource::LocalFile { path: resolved }
4645 }
4646 other => {
4647 return Err(McpError::new(
4648 ErrorCode::InvalidArgument,
4649 format!(
4650 "Unsupported temp_attach kind '{other}' for alias '{}'. Only 'local_file' is supported today.",
4651 spec.alias
4652 ),
4653 ));
4654 }
4655 };
4656 attach::validate_alias(&spec.alias)?;
4657 out.push(AttachRequest {
4658 alias: spec.alias.clone(),
4659 source,
4660 writable,
4661 on_missing,
4662 });
4663 }
4664 Ok(out)
4665}
4666
4667fn perform_copy(
4672 engine: &Engine,
4673 mode: &str,
4674 target_db: Option<&str>,
4675 target_table: &str,
4676 sql_body: &str,
4677) -> Result<Value, McpError> {
4678 let qualified = qualified_name(engine, target_db, target_table);
4679 let exists = target_exists(engine, target_db, target_table)?;
4680 let timer = crate::stats::StatsTimer::start();
4681
4682 match mode {
4683 "create" => {
4684 if exists {
4685 return Err(McpError::new(
4686 ErrorCode::InvalidArgument,
4687 format!(
4688 "Target '{target_table}' already exists. Use mode='append' to add rows or mode='replace' to drop and recreate."
4689 ),
4690 ));
4691 }
4692 engine.execute_command(&format!("CREATE TABLE {qualified} AS {sql_body}"))?;
4693 }
4694 "append" => {
4695 if !exists {
4696 return Err(McpError::new(
4697 ErrorCode::InvalidArgument,
4698 format!(
4699 "Target '{target_table}' does not exist. Use mode='create' to create it from the query or mode='replace' to drop and recreate."
4700 ),
4701 ));
4702 }
4703 engine.execute_command(&format!("INSERT INTO {qualified} {sql_body}"))?;
4704 }
4705 "replace" => {
4706 engine.execute_command(&format!("DROP TABLE IF EXISTS {qualified}"))?;
4715 engine.execute_command(&format!("CREATE TABLE {qualified} AS {sql_body}"))?;
4716 }
4717 other => {
4718 return Err(McpError::new(
4719 ErrorCode::InvalidArgument,
4720 format!("copy_query mode '{other}' is not supported"),
4721 ));
4722 }
4723 }
4724
4725 let elapsed_ms = timer.elapsed_ms();
4726 let row_count = count_rows(engine, target_db, target_table);
4727 Ok(json!({
4728 "target_table": target_table,
4729 "target_database": target_db.unwrap_or(LOCAL_ALIAS),
4730 "mode": mode,
4731 "row_count": row_count,
4732 "stats": { "operation": "copy_query", "elapsed_ms": elapsed_ms },
4733 }))
4734}
4735
4736#[cfg(test)]
4737mod validate_execute_batch_tests {
4738 use super::*;
4739
4740 fn s(v: &[&str]) -> Vec<String> {
4741 v.iter().map(|x| (*x).to_string()).collect()
4742 }
4743
4744 #[test]
4745 fn rejects_empty_array() {
4746 let err = validate_execute_batch(&[]).unwrap_err();
4747 assert_eq!(err.code, ErrorCode::InvalidArgument);
4748 }
4749
4750 #[test]
4751 fn rejects_whitespace_only_element() {
4752 let err = validate_execute_batch(&s(&[" "])).unwrap_err();
4753 assert_eq!(err.code, ErrorCode::InvalidArgument);
4754 assert!(err.message.contains("sql[0]"));
4755 let err = validate_execute_batch(&s(&["INSERT INTO t VALUES (1)", "/* */"])).unwrap_err();
4756 assert!(err.message.contains("sql[1]"));
4757 }
4758
4759 #[test]
4760 fn rejects_read_only_element() {
4761 let err =
4762 validate_execute_batch(&s(&["INSERT INTO t VALUES (1)", "SELECT 1"])).unwrap_err();
4763 assert_eq!(err.code, ErrorCode::SqlError);
4764 assert!(err.message.contains("sql[1]"));
4765 }
4766
4767 #[test]
4768 fn rejects_transaction_control_in_batch() {
4769 for sql in [
4772 "BEGIN",
4773 "COMMIT",
4774 "ROLLBACK",
4775 "SAVEPOINT sp1",
4776 "START TRANSACTION",
4777 "END",
4778 "RELEASE SAVEPOINT sp1",
4779 ] {
4780 let err = validate_execute_batch(&s(&["INSERT INTO t VALUES (1)", sql])).unwrap_err();
4781 assert_eq!(err.code, ErrorCode::InvalidArgument, "for `{sql}`");
4782 assert!(
4783 err.message.contains("transaction-control"),
4784 "for `{sql}`: {}",
4785 err.message
4786 );
4787 }
4788 }
4789
4790 #[test]
4791 fn rejects_transaction_control_singleton() {
4792 let err = validate_execute_batch(&s(&["BEGIN"])).unwrap_err();
4797 assert_eq!(err.code, ErrorCode::InvalidArgument);
4798 }
4799
4800 #[test]
4801 fn rejects_ddl_dml_mix() {
4802 let err =
4803 validate_execute_batch(&s(&["CREATE TABLE x (i INT)", "INSERT INTO x VALUES (1)"]))
4804 .unwrap_err();
4805 assert_eq!(err.code, ErrorCode::InvalidArgument);
4806 assert!(err.message.contains("DDL"));
4807 }
4808
4809 #[test]
4810 fn rejects_multi_ddl() {
4811 let err = validate_execute_batch(&s(&["CREATE TABLE a (i INT)", "CREATE TABLE b (j INT)"]))
4812 .unwrap_err();
4813 assert_eq!(err.code, ErrorCode::InvalidArgument);
4814 assert!(err.message.contains("Multi-statement DDL"));
4815 }
4816
4817 #[test]
4818 fn allows_single_ddl() {
4819 validate_execute_batch(&s(&["CREATE TABLE a (i INT)"])).unwrap();
4820 }
4821
4822 #[test]
4823 fn allows_multi_dml() {
4824 validate_execute_batch(&s(&[
4825 "UPDATE settings SET value = 'x' WHERE key = 'k'",
4826 "INSERT INTO settings (key, value) SELECT 'k', 'x' WHERE NOT EXISTS (SELECT 1 FROM settings WHERE key = 'k')",
4827 ]))
4828 .unwrap();
4829 }
4830
4831 #[test]
4832 fn allows_other_kinds() {
4833 validate_execute_batch(&s(&["SET schema_search_path = 'mydb'"])).unwrap();
4837 }
4838
4839 #[test]
4840 fn allows_trailing_semicolon() {
4841 validate_execute_batch(&s(&["INSERT INTO t VALUES (1);"])).unwrap();
4842 }
4843}