1use crate::attach::{self, AttachRegistry, AttachRequest, AttachSource, LOCAL_ALIAS};
15use crate::chart::{render_chart, ChartFormat, ChartOptions, ChartType};
16use crate::engine::{is_read_only_sql, Engine};
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: String,
389 pub database: Option<String>,
393}
394
395#[derive(Debug, Deserialize, JsonSchema)]
397pub struct SampleParams {
398 pub table: String,
400 pub n: Option<u64>,
402 pub database: Option<String>,
405}
406
407#[derive(Debug, Default, Deserialize, JsonSchema)]
411pub struct DescribeParams {
412 pub table: Option<String>,
415 pub database: Option<String>,
419}
420
421#[derive(Debug, Deserialize, JsonSchema)]
423pub struct ChartParams {
424 pub sql: String,
426 pub chart_type: String,
428 pub x: Option<String>,
430 pub y: Option<String>,
432 pub series: Option<String>,
434 pub title: Option<String>,
436 pub format: Option<String>,
438 pub width: Option<u32>,
440 pub height: Option<u32>,
442 pub bins: Option<u32>,
444 pub x_as_category: Option<bool>,
450 pub x_range: Option<[f64; 2]>,
455 pub y_range: Option<[f64; 2]>,
458 pub color_map: Option<std::collections::HashMap<String, String>>,
463 pub label_points: Option<bool>,
468 pub output_path: Option<String>,
474 pub inline: Option<bool>,
480 pub overwrite: Option<bool>,
484 pub database: Option<String>,
488}
489
490#[derive(Debug, Deserialize, JsonSchema)]
492pub struct WatchDirectoryParams {
493 pub path: String,
495 pub table: String,
497 #[serde(default)]
500 pub max_concurrent: Option<u32>,
501 pub database: Option<String>,
510 pub persist: Option<bool>,
513}
514
515#[derive(Debug, Deserialize, JsonSchema)]
517pub struct UnwatchDirectoryParams {
518 pub path: String,
520}
521
522#[derive(Debug, Deserialize, JsonSchema)]
532pub struct InspectFileParams {
533 pub path: String,
536 pub sample_rows: Option<u32>,
540 pub json_extract_path: Option<String>,
544}
545
546#[derive(Debug, Deserialize, JsonSchema)]
548pub struct ExportParams {
549 pub sql: Option<String>,
551 pub table: Option<String>,
553 pub path: String,
555 pub format: String,
560 pub overwrite: Option<bool>,
564 pub format_options: Option<Value>,
582 pub database: Option<String>,
588}
589
590#[derive(Debug, Deserialize, JsonSchema)]
604pub struct SaveQueryParams {
605 pub name: String,
608 pub sql: String,
612 pub description: Option<String>,
614}
615
616#[derive(Debug, Deserialize, JsonSchema)]
618pub struct DeleteQueryParams {
619 pub name: String,
622}
623
624#[derive(Debug, Deserialize, JsonSchema, Clone)]
629pub struct AttachSpec {
630 pub alias: String,
634 pub kind: String,
638 pub path: Option<String>,
641 pub writable: Option<bool>,
645 pub on_missing: Option<String>,
651}
652
653#[derive(Debug, Deserialize, JsonSchema)]
657pub struct AttachDatabaseParams {
658 pub alias: String,
662 pub kind: String,
664 pub path: Option<String>,
668 pub writable: Option<bool>,
672 pub on_missing: Option<String>,
683}
684
685#[derive(Debug, Deserialize, JsonSchema)]
687pub struct DetachDatabaseParams {
688 pub alias: String,
690}
691
692#[derive(Debug, Deserialize, JsonSchema)]
700pub struct CopyQueryParams {
701 pub sql: String,
706 pub target_table: String,
709 pub mode: String,
717 pub target_database: Option<String>,
721 pub temp_attach: Option<Vec<AttachSpec>>,
725}
726
727#[derive(Debug, Deserialize, JsonSchema)]
735pub struct SetTableMetadataParams {
736 pub table: String,
740 pub source_url: Option<String>,
742 pub source_description: Option<String>,
745 pub purpose: Option<String>,
748 pub license: Option<String>,
750 pub notes: Option<String>,
752 pub database: Option<String>,
760}
761
762#[derive(Debug, Deserialize, JsonSchema)]
766pub struct AnalyzeTableArgs {
767 pub table: String,
769}
770
771#[derive(Debug, Deserialize, JsonSchema)]
773pub struct CompareTablesArgs {
774 pub table_a: String,
776 pub table_b: String,
778}
779
780#[derive(Debug, Deserialize, JsonSchema)]
782pub struct DataQualityArgs {
783 pub table: String,
785}
786
787#[derive(Debug, Deserialize, JsonSchema)]
789pub struct SuggestQueriesArgs {
790 pub table: String,
792 pub goal: Option<String>,
794}
795
796pub struct HyperMcpServer {
805 engine: Arc<Mutex<Option<Engine>>>,
806 catalog_ready: Arc<Mutex<bool>>,
812 watchers: Arc<crate::watcher::WatcherRegistry>,
813 saved_queries: Arc<dyn SavedQueryStore>,
814 subscriptions: Arc<SubscriptionRegistry>,
815 attachments: Arc<AttachRegistry>,
821 workspace_path: Option<String>,
825 read_only: bool,
826 no_daemon: bool,
828 last_heartbeat: std::sync::Mutex<std::time::Instant>,
830 client_name: std::sync::Mutex<Option<String>>,
834 #[expect(dead_code, reason = "constructed for rmcp 1.x macro-based dispatch")]
839 tool_router: ToolRouter<Self>,
840 #[expect(dead_code, reason = "constructed for rmcp 1.x macro-based dispatch")]
841 prompt_router: PromptRouter<Self>,
842}
843
844impl std::fmt::Debug for HyperMcpServer {
845 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
846 f.debug_struct("HyperMcpServer")
847 .field("persistent_path", &self.workspace_path)
848 .field("read_only", &self.read_only)
849 .field("no_daemon", &self.no_daemon)
850 .finish_non_exhaustive()
851 }
852}
853
854impl HyperMcpServer {
855 pub fn new(persistent_path: Option<String>, read_only: bool) -> Self {
875 Self::with_options(persistent_path, read_only, false)
876 }
877
878 pub fn with_no_daemon(
880 persistent_path: Option<String>,
881 read_only: bool,
882 no_daemon: bool,
883 ) -> Self {
884 Self::with_options(persistent_path, read_only, no_daemon)
885 }
886
887 fn with_options(persistent_path: Option<String>, read_only: bool, no_daemon: bool) -> Self {
888 let saved_queries: Arc<dyn SavedQueryStore> = build_store(persistent_path.as_deref());
891 Self {
892 engine: Arc::new(Mutex::new(None)),
893 catalog_ready: Arc::new(Mutex::new(false)),
894 watchers: Arc::new(crate::watcher::WatcherRegistry::new()),
895 saved_queries,
896 subscriptions: Arc::new(SubscriptionRegistry::new()),
897 attachments: Arc::new(AttachRegistry::new()),
902 workspace_path: persistent_path,
903 read_only,
904 no_daemon,
905 last_heartbeat: std::sync::Mutex::new(std::time::Instant::now()),
906 client_name: std::sync::Mutex::new(None),
907 tool_router: Self::tool_router(),
908 prompt_router: Self::prompt_router(),
909 }
910 }
911
912 #[must_use]
916 pub fn subscriptions_handle(&self) -> Arc<SubscriptionRegistry> {
917 Arc::clone(&self.subscriptions)
918 }
919
920 pub(crate) fn notify_table_changed(&self, table: &str) {
927 for uri in uris_for_table_change(table) {
928 self.subscriptions.notify_updated(&uri);
929 }
930 }
931
932 pub(crate) fn notify_workspace_changed(&self) {
937 for uri in uris_for_workspace_change() {
938 self.subscriptions.notify_updated(uri);
939 }
940 }
941
942 pub(crate) fn notify_resource_list_changed(&self) {
947 self.subscriptions.notify_list_changed();
948 }
949
950 fn client_name(&self) -> Option<String> {
953 self.client_name.lock().ok().and_then(|g| g.clone())
954 }
955
956 #[must_use]
959 pub fn engine_handle(&self) -> Arc<Mutex<Option<Engine>>> {
960 Arc::clone(&self.engine)
961 }
962
963 #[must_use]
965 pub fn watchers_handle(&self) -> Arc<crate::watcher::WatcherRegistry> {
966 Arc::clone(&self.watchers)
967 }
968
969 #[must_use]
972 pub fn attachments_handle(&self) -> Arc<AttachRegistry> {
973 Arc::clone(&self.attachments)
974 }
975
976 #[must_use]
978 pub fn is_read_only(&self) -> bool {
979 self.read_only
980 }
981
982 fn check_writable(&self, operation: &str) -> Result<(), McpError> {
985 if self.read_only {
986 Err(McpError::new(
987 ErrorCode::ReadOnlyViolation,
988 format!("Operation '{operation}' is not permitted in read-only mode"),
989 ))
990 } else {
991 Ok(())
992 }
993 }
994
995 fn resolve_db(
1004 &self,
1005 engine: &Engine,
1006 database: Option<&str>,
1007 persist: Option<bool>,
1008 require_writable: bool,
1009 ) -> Result<Option<String>, McpError> {
1010 let effective = match (database, persist) {
1011 (Some(db), _) => Some(db),
1012 (None, Some(true)) => Some(Engine::PERSISTENT_ALIAS),
1013 _ => None,
1014 };
1015 let effective = effective.filter(|s| !s.eq_ignore_ascii_case(crate::attach::LOCAL_ALIAS));
1017
1018 let resolved = engine.resolve_target_db(effective)?;
1019 let primary = engine.primary_db_name();
1020
1021 if resolved == primary {
1022 return Ok(None);
1023 }
1024
1025 if require_writable && resolved != Engine::PERSISTENT_ALIAS {
1026 match self.attachments.get(&resolved) {
1027 None => {
1028 return Err(McpError::new(
1029 ErrorCode::InvalidArgument,
1030 format!(
1031 "database '{resolved}' is not attached. \
1032 Call attach_database first, or use \"persistent\"."
1033 ),
1034 ));
1035 }
1036 Some(entry) if !entry.writable => {
1037 return Err(McpError::new(
1038 ErrorCode::InvalidArgument,
1039 format!(
1040 "database '{resolved}' was attached read-only. \
1041 Re-attach with writable:true to write to it."
1042 ),
1043 ));
1044 }
1045 _ => {}
1046 }
1047 }
1048
1049 Ok(Some(resolved))
1050 }
1051
1052 fn ensure_engine(&self) -> Result<std::sync::MutexGuard<'_, Option<Engine>>, McpError> {
1061 let mut guard = self
1062 .engine
1063 .lock()
1064 .map_err(|_| McpError::new(ErrorCode::InternalError, "Lock poisoned"))?;
1065 if guard.is_none() {
1066 tracing::info!(
1067 persistent_db = self.workspace_path.as_deref().unwrap_or("<ephemeral-only>"),
1068 no_daemon = self.no_daemon,
1069 "initializing hyper engine"
1070 );
1071 let engine = if self.no_daemon {
1072 Engine::new_no_daemon(self.workspace_path.clone())?
1073 } else {
1074 Engine::new(self.workspace_path.clone())?
1075 };
1076 tracing::info!(
1077 ephemeral_path = %engine.ephemeral_path().display(),
1078 persistent_path = ?engine.persistent_path(),
1079 log_dir = %engine.log_dir().display(),
1080 "engine ready"
1081 );
1082 if let Err(e) = self.attachments.replay_all(&engine) {
1091 tracing::warn!(err = %e.message, "failed to replay attachments on new engine");
1092 }
1093 *guard = Some(engine);
1094 if let Ok(mut ready) = self.catalog_ready.lock() {
1098 *ready = false;
1099 }
1100 }
1101 Ok(guard)
1102 }
1103
1104 fn ensure_catalog_ready(&self, engine: &Engine) {
1113 if self.read_only {
1114 return;
1115 }
1116 let Ok(mut ready) = self.catalog_ready.lock() else {
1117 return;
1118 };
1119 if *ready {
1120 return;
1121 }
1122 if let Err(e) = crate::table_catalog::ensure_exists(engine) {
1123 tracing::warn!(err = %e.message, "failed to ensure _table_catalog exists");
1124 }
1125 if let Err(e) = crate::table_catalog::reconcile(engine) {
1126 tracing::warn!(err = %e.message, "failed to reconcile _table_catalog on startup");
1127 }
1128 *ready = true;
1129 }
1130
1131 fn after_ingest_catalog_update(
1144 &self,
1145 engine: &Engine,
1146 table_name: &str,
1147 load_tool: &'static str,
1148 load_params: Option<&str>,
1149 row_count: Option<i64>,
1150 target_db: Option<&str>,
1151 ) {
1152 if let Err(e) = crate::table_catalog::upsert_stub_in(
1153 engine,
1154 table_name,
1155 load_tool,
1156 load_params,
1157 row_count,
1158 true,
1159 target_db,
1160 self.client_name().as_deref(),
1161 ) {
1162 tracing::warn!(
1163 table = %table_name,
1164 target_db = ?target_db,
1165 err = %e.message,
1166 "failed to update _table_catalog after ingest"
1167 );
1168 }
1169 }
1170
1171 #[expect(
1181 clippy::unused_self,
1182 reason = "&self required for method-call dispatch; body uses only engine + target_db"
1183 )]
1184 fn after_execute_catalog_update(&self, engine: &Engine, target_db: Option<&str>) {
1185 if let Err(e) = crate::table_catalog::reconcile_in(engine, None) {
1186 tracing::warn!(
1187 err = %e.message,
1188 "failed to reconcile persistent _table_catalog after execute"
1189 );
1190 }
1191 if let Some(alias) = target_db {
1192 if !alias.eq_ignore_ascii_case(Engine::PERSISTENT_ALIAS) {
1193 if let Err(e) = crate::table_catalog::reconcile_in(engine, Some(alias)) {
1194 tracing::warn!(
1195 target_db = alias,
1196 err = %e.message,
1197 "failed to reconcile user-DB _table_catalog after execute"
1198 );
1199 }
1200 }
1201 }
1202 }
1203
1204 fn with_engine<F, R>(&self, f: F) -> Result<R, McpError>
1213 where
1214 F: FnOnce(&Engine) -> Result<R, McpError>,
1215 {
1216 let mut guard = self.ensure_engine()?;
1217 let engine = guard.as_ref().expect("ensure_engine guarantees Some");
1218 self.ensure_catalog_ready(engine);
1223 if !self.no_daemon {
1226 self.maybe_send_heartbeat();
1227 }
1228 let result = f(engine);
1229 if let Err(e) = &result {
1230 tracing::debug!(code = ?e.code, message = %e.message, "tool call returned error");
1231 if e.code == ErrorCode::ConnectionLost {
1232 tracing::warn!(
1233 "connection to hyperd lost or desynchronized ({}); \
1238 dropping engine so next call reconnects",
1239 e.message
1240 );
1241 *guard = None;
1242 if let Ok(mut ready) = self.catalog_ready.lock() {
1245 *ready = false;
1246 }
1247 if !self.no_daemon {
1251 crate::daemon::health::report_hyperd_error_to_daemon();
1252 }
1253 }
1254 }
1255 result
1256 }
1257
1258 fn maybe_send_heartbeat(&self) {
1262 const HEARTBEAT_INTERVAL: std::time::Duration = std::time::Duration::from_secs(60);
1263 let should_send = self
1264 .last_heartbeat
1265 .lock()
1266 .is_ok_and(|guard| guard.elapsed() >= HEARTBEAT_INTERVAL);
1267 if should_send {
1268 let port = crate::daemon::discovery::resolve_port();
1269 let _ = crate::daemon::health::send_command(port, "HEARTBEAT");
1270 if let Ok(mut guard) = self.last_heartbeat.lock() {
1271 *guard = std::time::Instant::now();
1272 }
1273 }
1274 }
1275
1276 fn with_saved_query_store<F, R>(&self, f: F) -> Result<R, McpError>
1286 where
1287 F: FnOnce(Option<&Engine>) -> Result<R, McpError>,
1288 {
1289 if self.workspace_path.is_some() {
1290 self.with_engine(|engine| f(Some(engine)))
1291 } else {
1292 f(None)
1293 }
1294 }
1295
1296 #[expect(
1297 clippy::unnecessary_wraps,
1298 reason = "signature retained for API symmetry / future fallibility; returning Result/Option keeps callers from breaking when the function later grows failure cases"
1299 )]
1300 fn ok_content(val: Value) -> Result<CallToolResult, rmcp::ErrorData> {
1305 let text = serde_json::to_string_pretty(&val).unwrap_or_default();
1306 let mut result = CallToolResult::structured(val);
1307 result.content = vec![Content::text(text)];
1311 Ok(result)
1312 }
1313
1314 fn fmt_sql(sql: &str) -> String {
1317 let opts = FormatOptions {
1318 indent: Indent::Spaces(2),
1319 uppercase: Some(true),
1320 lines_between_queries: 1,
1321 ..FormatOptions::default()
1322 };
1323 let formatted = sqlformat::format(sql, &SqlQueryParams::None, &opts);
1324 if formatted.trim().is_empty() {
1325 sql.to_owned()
1326 } else {
1327 formatted
1328 }
1329 }
1330
1331 #[expect(
1332 clippy::unnecessary_wraps,
1333 reason = "signature retained for API symmetry / future fallibility; returning Result/Option keeps callers from breaking when the function later grows failure cases"
1334 )]
1335 #[expect(
1336 clippy::needless_pass_by_value,
1337 reason = "call-site ergonomics: function consumes logically-owned parameters, refactoring signatures is not worth per-site churn"
1338 )]
1339 fn err_content(e: McpError) -> Result<CallToolResult, rmcp::ErrorData> {
1344 let err_val = serde_json::to_value(&e).unwrap_or(Value::String(e.to_string()));
1345 let body = json!({"error": err_val});
1346 let text = serde_json::to_string_pretty(&body).unwrap_or_default();
1347 let mut result = CallToolResult::structured_error(body);
1348 result.content = vec![Content::text(text)];
1349 Ok(result)
1350 }
1351}
1352
1353#[tool_router]
1354impl HyperMcpServer {
1355 #[tool(
1357 description = "Ingest inline data (JSON or CSV) and run a SQL query in one call. Creates a temp table, queries, discards."
1358 )]
1359 fn query_data(
1360 &self,
1361 Parameters(params): Parameters<QueryDataParams>,
1362 ) -> Result<CallToolResult, rmcp::ErrorData> {
1363 let result = self.with_engine(|engine| {
1364 let tname = params.table_name.unwrap_or_else(|| "data".into());
1365 let temp_table = format!("_tmp_{}_{}", tname, rand_suffix());
1366 let fmt = params.format.unwrap_or_else(|| detect_format(¶ms.data));
1367 let schema_override = crate::schema::normalize_schema_param(params.schema.as_ref())?;
1368 let opts = IngestOptions {
1369 table: temp_table.clone(),
1370 mode: "replace".into(),
1371 schema_override,
1372 merge_key: None,
1373 target_db: None,
1374 };
1375
1376 let ingest_result = match fmt.as_str() {
1377 "csv" => ingest_csv(engine, ¶ms.data, &opts),
1378 _ => ingest_json(engine, ¶ms.data, &opts),
1379 }?;
1380
1381 let query_sql = params.sql.replace(&tname, &temp_table);
1382 let rows = engine.execute_query_to_json(&query_sql)?;
1383 let _ = engine.execute_command(&format!("DROP TABLE IF EXISTS \"{temp_table}\""));
1384
1385 Ok(json!({
1386 "sql": Self::fmt_sql(¶ms.sql),
1387 "result": rows,
1388 "stats": ingest_result.stats.to_json(),
1389 }))
1390 });
1391
1392 match result {
1393 Ok(val) => Self::ok_content(val),
1394 Err(e) => Self::err_content(e),
1395 }
1396 }
1397
1398 #[tool(
1400 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."
1401 )]
1402 fn query_file(
1403 &self,
1404 Parameters(params): Parameters<QueryFileParams>,
1405 ) -> Result<CallToolResult, rmcp::ErrorData> {
1406 let result = self.with_engine(|engine| {
1407 crate::attach::validate_input_path(¶ms.path, "data file")?;
1408 let stem = std::path::Path::new(¶ms.path)
1409 .file_stem()
1410 .and_then(|s| s.to_str())
1411 .unwrap_or("file")
1412 .to_string();
1413 let tname = params.table_name.unwrap_or_else(|| stem.clone());
1414 let temp_table = format!("_tmp_{}_{}", tname, rand_suffix());
1415 let schema_override = crate::schema::normalize_schema_param(params.schema.as_ref())?;
1416 let opts = IngestOptions {
1417 table: temp_table.clone(),
1418 mode: "replace".into(),
1419 schema_override,
1420 merge_key: None,
1421 target_db: None,
1422 };
1423
1424 let ingest_result = if let Some(ref json_path) = params.json_extract_path {
1425 let raw = std::fs::read_to_string(¶ms.path).map_err(|e| {
1426 McpError::new(
1427 ErrorCode::FileNotFound,
1428 format!("Cannot read file '{}': {e}", params.path),
1429 )
1430 })?;
1431 let extracted = crate::ingest::extract_json_path(&raw, json_path)?;
1432 let array_text = crate::ingest::normalize_json_or_jsonl(&extracted)?;
1433 let mut result = ingest_json(engine, &array_text, &opts)?;
1434 result.stats.operation = "query_file".into();
1435 result.stats.bytes_read = std::fs::metadata(¶ms.path).map_or(0, |m| m.len());
1436 result.stats.file_format = Some("json".into());
1437 result
1438 } else {
1439 match detect_file_format(std::path::Path::new(¶ms.path)) {
1440 InferredFileFormat::Parquet => ingest_parquet_file(engine, ¶ms.path, &opts),
1441 InferredFileFormat::ArrowIpc => {
1442 ingest_arrow_ipc_file(engine, ¶ms.path, &opts)
1443 }
1444 InferredFileFormat::Json => ingest_json_file(engine, ¶ms.path, &opts),
1445 InferredFileFormat::Csv => ingest_csv_file(engine, ¶ms.path, &opts),
1446 }?
1447 };
1448
1449 let query_sql = params.sql.replace(&tname, &temp_table);
1450 let rows = engine.execute_query_to_json(&query_sql)?;
1451 let _ = engine.execute_command(&format!("DROP TABLE IF EXISTS \"{temp_table}\""));
1452
1453 Ok(json!({
1454 "sql": Self::fmt_sql(¶ms.sql),
1455 "result": rows,
1456 "stats": ingest_result.stats.to_json(),
1457 }))
1458 });
1459
1460 match result {
1461 Ok(val) => Self::ok_content(val),
1462 Err(e) => Self::err_content(e),
1463 }
1464 }
1465
1466 #[tool(
1468 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))."
1469 )]
1470 fn load_data(
1471 &self,
1472 Parameters(params): Parameters<LoadDataParams>,
1473 ) -> Result<CallToolResult, rmcp::ErrorData> {
1474 if let Err(e) = self.check_writable("load_data") {
1475 return Self::err_content(e);
1476 }
1477 let table_name = params.table.clone();
1478 let mode = params.mode.clone().unwrap_or_else(|| "replace".into());
1483 let result = self.with_engine(|engine| {
1484 let target_db =
1485 self.resolve_db(engine, params.database.as_deref(), params.persist, true)?;
1486 let fmt = params.format.unwrap_or_else(|| detect_format(¶ms.data));
1487 let schema_override = crate::schema::normalize_schema_param(params.schema.as_ref())?;
1488 let opts = IngestOptions {
1489 table: params.table.clone(),
1490 mode: mode.clone(),
1491 schema_override,
1492 merge_key: None,
1493 target_db: target_db.clone(),
1494 };
1495
1496 let ingest_result = match fmt.as_str() {
1497 "csv" => ingest_csv(engine, ¶ms.data, &opts),
1498 _ => ingest_json(engine, ¶ms.data, &opts),
1499 }?;
1500
1501 let schema_json: Vec<Value> = ingest_result
1502 .schema
1503 .iter()
1504 .map(|c| {
1505 json!({
1506 "name": c.name,
1507 "type": c.hyper_type,
1508 "nullable": c.nullable,
1509 })
1510 })
1511 .collect();
1512
1513 {
1519 let load_params = serde_json::to_string(&json!({
1520 "mode": mode,
1521 "format": fmt,
1522 "database": target_db.as_deref().unwrap_or("local"),
1523 }))
1524 .ok();
1525 self.after_ingest_catalog_update(
1526 engine,
1527 ¶ms.table,
1528 "load_data",
1529 load_params.as_deref(),
1530 i64::try_from(ingest_result.rows).ok(),
1531 target_db.as_deref(),
1532 );
1533 }
1534
1535 Ok(json!({
1536 "rows": ingest_result.rows,
1537 "schema": schema_json,
1538 "stats": ingest_result.stats.to_json(),
1539 }))
1540 });
1541
1542 match result {
1543 Ok(val) => {
1544 self.notify_table_changed(&table_name);
1545 if mode == "replace" {
1546 self.notify_resource_list_changed();
1550 }
1551 Self::ok_content(val)
1552 }
1553 Err(e) => Self::err_content(e),
1554 }
1555 }
1556
1557 #[tool(
1559 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."
1560 )]
1561 fn load_file(
1562 &self,
1563 Parameters(params): Parameters<LoadFileParams>,
1564 ) -> Result<CallToolResult, rmcp::ErrorData> {
1565 if let Err(e) = self.check_writable("load_file") {
1566 return Self::err_content(e);
1567 }
1568 let table_name = params.table.clone();
1569 let mode = params.mode.clone().unwrap_or_else(|| "replace".into());
1570 let merge_key_vec = match validate_merge_args(&mode, params.merge_key) {
1574 Ok(v) => v,
1575 Err(e) => return Self::err_content(e),
1576 };
1577 let result = self.with_engine(|engine| {
1582 let target_db =
1583 self.resolve_db(engine, params.database.as_deref(), params.persist, true)?;
1584 crate::attach::validate_input_path(¶ms.path, "data file")?;
1585 let schema_override = crate::schema::normalize_schema_param(params.schema.as_ref())?;
1586 let opts = IngestOptions {
1587 table: params.table.clone(),
1588 mode: mode.clone(),
1589 schema_override,
1590 merge_key: merge_key_vec.clone(),
1591 target_db: target_db.clone(),
1592 };
1593
1594 let ingest_result = if let Some(ref json_path) = params.json_extract_path {
1595 let raw = std::fs::read_to_string(¶ms.path).map_err(|e| {
1596 McpError::new(
1597 ErrorCode::FileNotFound,
1598 format!("Cannot read file '{}': {e}", params.path),
1599 )
1600 })?;
1601 let extracted = crate::ingest::extract_json_path(&raw, json_path)?;
1602 let array_text = crate::ingest::normalize_json_or_jsonl(&extracted)?;
1603 let mut result = ingest_json(engine, &array_text, &opts)?;
1604 result.stats.operation = "load_file".into();
1605 result.stats.bytes_read = std::fs::metadata(¶ms.path).map_or(0, |m| m.len());
1606 result.stats.file_format = Some("json".into());
1607 result
1608 } else {
1609 match detect_file_format(std::path::Path::new(¶ms.path)) {
1610 InferredFileFormat::Parquet => ingest_parquet_file(engine, ¶ms.path, &opts),
1611 InferredFileFormat::ArrowIpc => {
1612 ingest_arrow_ipc_file(engine, ¶ms.path, &opts)
1613 }
1614 InferredFileFormat::Json => ingest_json_file(engine, ¶ms.path, &opts),
1615 InferredFileFormat::Csv => ingest_csv_file(engine, ¶ms.path, &opts),
1616 }?
1617 };
1618
1619 let schema_changed = ingest_result.stats.schema_changed;
1623
1624 let schema_json: Vec<Value> = ingest_result
1625 .schema
1626 .iter()
1627 .map(|c| {
1628 json!({
1629 "name": c.name,
1630 "type": c.hyper_type,
1631 "nullable": c.nullable,
1632 })
1633 })
1634 .collect();
1635
1636 {
1638 let load_params = serde_json::to_string(&json!({
1639 "source_path": params.path,
1640 "mode": mode,
1641 "schema": params.schema,
1642 "json_extract_path": params.json_extract_path,
1643 "merge_key": merge_key_vec,
1644 "database": target_db.as_deref().unwrap_or("local"),
1645 }))
1646 .ok();
1647 self.after_ingest_catalog_update(
1648 engine,
1649 ¶ms.table,
1650 "load_file",
1651 load_params.as_deref(),
1652 i64::try_from(ingest_result.rows).ok(),
1653 target_db.as_deref(),
1654 );
1655 }
1656
1657 Ok((
1658 json!({
1659 "rows": ingest_result.rows,
1660 "schema": schema_json,
1661 "stats": ingest_result.stats.to_json(),
1662 }),
1663 schema_changed,
1664 ))
1665 });
1666
1667 match result {
1668 Ok((val, schema_changed)) => {
1669 self.notify_table_changed(&table_name);
1670 if mode == "replace" || schema_changed {
1678 self.notify_resource_list_changed();
1679 }
1680 Self::ok_content(val)
1681 }
1682 Err(e) => Self::err_content(e),
1683 }
1684 }
1685
1686 #[tool(
1690 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.**"
1691 )]
1692 fn load_files(
1693 &self,
1694 Parameters(params): Parameters<LoadFilesParams>,
1695 ) -> Result<CallToolResult, rmcp::ErrorData> {
1696 use hyperdb_api::pool::{create_pool, PoolConfig};
1697 use hyperdb_api::CreateMode;
1698
1699 if let Err(e) = self.check_writable("load_files") {
1700 return Self::err_content(e);
1701 }
1702 if params.files.is_empty() {
1703 return Self::err_content(McpError::new(
1704 ErrorCode::EmptyData,
1705 "load_files: `files` must not be empty",
1706 ));
1707 }
1708
1709 for (idx, entry) in params.files.iter().enumerate() {
1716 if let Err(mut e) = crate::attach::validate_input_path(&entry.path, "data file") {
1717 e.message = format!("entry {idx} (table '{}'): {}", entry.table, e.message);
1718 return Self::err_content(e);
1719 }
1720 let mode = entry.mode.as_deref().unwrap_or("replace");
1721 if mode == "merge" || entry.merge_key.is_some() {
1722 return Self::err_content(McpError::new(
1723 ErrorCode::InvalidArgument,
1724 format!(
1725 "load_files does not support mode=merge yet (entry {idx}, table \
1726 '{}'). Call load_file once per file when you need merge semantics.",
1727 entry.table
1728 ),
1729 ));
1730 }
1731 }
1732
1733 let (endpoint, workspace, target_db) = match self.with_engine(|engine| {
1739 let target_db =
1740 self.resolve_db(engine, params.database.as_deref(), params.persist, true)?;
1741 let endpoint = engine.hyperd_endpoint()?;
1742 let workspace = match target_db.as_deref() {
1743 None => engine.ephemeral_path().to_string_lossy().to_string(),
1744 Some(alias) if alias.eq_ignore_ascii_case(Engine::PERSISTENT_ALIAS) => engine
1745 .persistent_path()
1746 .ok_or_else(|| {
1747 McpError::new(
1748 ErrorCode::InvalidArgument,
1749 "target 'persistent' but the server is in --ephemeral-only mode",
1750 )
1751 })?
1752 .to_string_lossy()
1753 .to_string(),
1754 Some(alias) => {
1755 let entry = self.attachments.get(alias).ok_or_else(|| {
1756 McpError::new(
1757 ErrorCode::InvalidArgument,
1758 format!("database '{alias}' is not attached"),
1759 )
1760 })?;
1761 let crate::attach::AttachSource::LocalFile { path } = &entry.source;
1762 path.to_string_lossy().to_string()
1763 }
1764 };
1765 Ok((endpoint, workspace, target_db))
1766 }) {
1767 Ok(v) => v,
1768 Err(e) => return Self::err_content(e),
1769 };
1770
1771 let file_count = params.files.len();
1774 let concurrency = params
1775 .concurrency
1776 .map_or(8, |n| n as usize)
1777 .min(file_count)
1778 .clamp(1, 16);
1779
1780 let pool = match create_pool(
1781 PoolConfig::new(endpoint, workspace)
1782 .create_mode(CreateMode::DoNotCreate)
1783 .max_size(concurrency),
1784 ) {
1785 Ok(p) => Arc::new(p),
1786 Err(e) => {
1787 return Self::err_content(McpError::new(
1788 ErrorCode::InternalError,
1789 format!("Failed to build connection pool for load_files: {e}"),
1790 ))
1791 }
1792 };
1793
1794 let Ok(rt) = tokio::runtime::Handle::try_current() else {
1797 return Self::err_content(McpError::new(
1798 ErrorCode::InternalError,
1799 "load_files must run inside a tokio runtime",
1800 ));
1801 };
1802
1803 #[derive(Default)]
1806 struct EntryOutcome {
1807 table: String,
1808 ok: Option<(u64, Vec<Value>, Value)>,
1809 err: Option<(ErrorCode, String)>,
1810 replace_mode: bool,
1811 }
1812
1813 let outcomes: Vec<EntryOutcome> = tokio::task::block_in_place(|| {
1814 rt.block_on(async {
1815 let mut set = tokio::task::JoinSet::new();
1816 for (idx, entry) in params.files.into_iter().enumerate() {
1817 let pool = Arc::clone(&pool);
1818 let entry_target_db = target_db.clone();
1819 set.spawn(async move {
1820 let mode = entry.mode.clone().unwrap_or_else(|| "replace".into());
1821 let replace_mode = mode == "replace";
1822 let mut out = EntryOutcome {
1823 table: entry.table.clone(),
1824 replace_mode,
1825 ..Default::default()
1826 };
1827
1828 let schema_override =
1833 match crate::schema::normalize_schema_param(entry.schema.as_ref()) {
1834 Ok(v) => v,
1835 Err(e) => {
1836 out.err = Some((e.code, e.message));
1837 return (idx, out);
1838 }
1839 };
1840 let _ = &entry_target_db;
1849 let opts = IngestOptions {
1850 table: entry.table.clone(),
1851 mode: mode.clone(),
1852 schema_override,
1853 merge_key: None,
1854 target_db: None,
1855 };
1856
1857 let conn = match pool.get().await {
1860 Ok(c) => c,
1861 Err(e) => {
1862 out.err = Some((
1863 ErrorCode::InternalError,
1864 format!("Failed to check out connection: {e}"),
1865 ));
1866 return (idx, out);
1867 }
1868 };
1869
1870 let ingest_res = if let Some(ref json_path) = entry.json_extract_path {
1875 let raw = match std::fs::read_to_string(&entry.path) {
1876 Ok(s) => s,
1877 Err(e) => {
1878 out.err = Some((
1879 ErrorCode::FileNotFound,
1880 format!("Cannot read file '{}': {e}", entry.path),
1881 ));
1882 return (idx, out);
1883 }
1884 };
1885 let extracted = match crate::ingest::extract_json_path(&raw, json_path)
1886 {
1887 Ok(v) => v,
1888 Err(e) => {
1889 out.err = Some((e.code, e.message));
1890 return (idx, out);
1891 }
1892 };
1893 let array_text =
1894 match crate::ingest::normalize_json_or_jsonl(&extracted) {
1895 Ok(v) => v,
1896 Err(e) => {
1897 out.err = Some((e.code, e.message));
1898 return (idx, out);
1899 }
1900 };
1901 crate::ingest::ingest_json_async(&conn, &array_text, &opts)
1902 .await
1903 .map(|mut r| {
1904 r.stats.operation = "load_file".into();
1905 r.stats.bytes_read =
1906 std::fs::metadata(&entry.path).map_or(0, |m| m.len());
1907 r.stats.file_format = Some("json".into());
1908 r
1909 })
1910 } else {
1911 match detect_file_format(std::path::Path::new(&entry.path)) {
1912 InferredFileFormat::Parquet => {
1913 ingest_parquet_file_async(&conn, &entry.path, &opts).await
1914 }
1915 InferredFileFormat::ArrowIpc => {
1916 ingest_arrow_ipc_file_async(&conn, &entry.path, &opts).await
1917 }
1918 InferredFileFormat::Json => {
1919 ingest_json_file_async(&conn, &entry.path, &opts).await
1920 }
1921 InferredFileFormat::Csv => {
1922 ingest_csv_file_async(&conn, &entry.path, &opts).await
1923 }
1924 }
1925 };
1926
1927 match ingest_res {
1928 Ok(r) => {
1929 let schema_json: Vec<Value> = r
1930 .schema
1931 .iter()
1932 .map(|c| {
1933 json!({
1934 "name": c.name,
1935 "type": c.hyper_type,
1936 "nullable": c.nullable,
1937 })
1938 })
1939 .collect();
1940 out.ok = Some((r.rows, schema_json, r.stats.to_json()));
1941 }
1942 Err(e) => {
1943 out.err = Some((e.code, e.message));
1944 }
1945 }
1946
1947 (idx, out)
1948 });
1949 }
1950
1951 let mut collected: Vec<Option<EntryOutcome>> =
1954 (0..file_count).map(|_| None).collect();
1955 while let Some(joined) = set.join_next().await {
1956 match joined {
1957 Ok((idx, outcome)) => collected[idx] = Some(outcome),
1958 Err(e) => {
1959 tracing::warn!("load_files task join error: {e}");
1962 }
1963 }
1964 }
1965 collected.into_iter().flatten().collect()
1966 })
1967 });
1968
1969 let mut any_replace_succeeded = false;
1973 let mut tables_to_notify: Vec<String> = Vec::new();
1974 let results_json: Vec<Value> = outcomes
1975 .iter()
1976 .map(|o| match (&o.ok, &o.err) {
1977 (Some((rows, schema, stats)), _) => {
1978 tables_to_notify.push(o.table.clone());
1979 if o.replace_mode {
1980 any_replace_succeeded = true;
1981 }
1982 json!({
1983 "table": o.table,
1984 "rows": rows,
1985 "schema": schema,
1986 "stats": stats,
1987 })
1988 }
1989 (None, Some((code, msg))) => json!({
1990 "table": o.table,
1991 "error": {
1992 "code": format!("{:?}", code),
1993 "message": msg,
1994 }
1995 }),
1996 (None, None) => json!({
1999 "table": o.table,
2000 "error": {
2001 "code": "InternalError",
2002 "message": "load_files task produced no outcome",
2003 }
2004 }),
2005 })
2006 .collect();
2007
2008 if let Err(e) = self.with_engine(|engine| {
2012 for o in &outcomes {
2013 if let Some((rows, _, _)) = &o.ok {
2014 self.after_ingest_catalog_update(
2015 engine,
2016 &o.table,
2017 "load_file",
2018 None,
2019 i64::try_from(*rows).ok(),
2020 target_db.as_deref(),
2021 );
2022 }
2023 }
2024 Ok(())
2025 }) {
2026 tracing::warn!("load_files: catalog update batch failed: {}", e.message);
2027 }
2028
2029 for t in &tables_to_notify {
2030 self.notify_table_changed(t);
2031 }
2032 if any_replace_succeeded {
2033 self.notify_resource_list_changed();
2034 }
2035
2036 let success_count = outcomes.iter().filter(|o| o.ok.is_some()).count();
2037 let failure_count = outcomes.len() - success_count;
2038
2039 Self::ok_content(json!({
2040 "results": results_json,
2041 "summary": {
2042 "total": outcomes.len(),
2043 "succeeded": success_count,
2044 "failed": failure_count,
2045 "concurrency": concurrency,
2046 }
2047 }))
2048 }
2049
2050 #[tool(
2053 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."
2054 )]
2055 fn load_iceberg(
2056 &self,
2057 Parameters(params): Parameters<LoadIcebergParams>,
2058 ) -> Result<CallToolResult, rmcp::ErrorData> {
2059 if let Err(e) = self.check_writable("load_iceberg") {
2060 return Self::err_content(e);
2061 }
2062 let table_name = params.table.clone();
2063 let mode = params.mode.clone().unwrap_or_else(|| "replace".into());
2064 let opts = crate::lakehouse::IcebergIngestOptions {
2065 table: params.table.clone(),
2066 mode: mode.clone(),
2067 metadata_filename: params.metadata_filename.clone(),
2068 version_as_of: params.version_as_of,
2069 };
2070
2071 let result = self.with_engine(|engine| {
2072 crate::attach::validate_input_path(¶ms.path, "iceberg table")?;
2074 let ingest_result =
2075 crate::lakehouse::ingest_iceberg_table(engine, ¶ms.path, &opts)?;
2076
2077 let schema_json: Vec<Value> = ingest_result
2078 .schema
2079 .iter()
2080 .map(|c| {
2081 json!({
2082 "name": c.name,
2083 "type": c.hyper_type,
2084 "nullable": c.nullable,
2085 })
2086 })
2087 .collect();
2088
2089 let load_params = serde_json::to_string(&json!({
2090 "source_path": params.path,
2091 "mode": mode,
2092 "format": "iceberg",
2093 "metadata_filename": params.metadata_filename,
2094 "version_as_of": params.version_as_of,
2095 }))
2096 .ok();
2097 self.after_ingest_catalog_update(
2098 engine,
2099 ¶ms.table,
2100 "load_iceberg",
2101 load_params.as_deref(),
2102 i64::try_from(ingest_result.rows).ok(),
2103 None,
2104 );
2105
2106 Ok(json!({
2107 "rows": ingest_result.rows,
2108 "schema": schema_json,
2109 "stats": ingest_result.stats.to_json(),
2110 }))
2111 });
2112
2113 match result {
2114 Ok(val) => {
2115 self.notify_table_changed(&table_name);
2116 if mode == "replace" {
2117 self.notify_resource_list_changed();
2118 }
2119 Self::ok_content(val)
2120 }
2121 Err(e) => Self::err_content(e),
2122 }
2123 }
2124
2125 #[tool(
2127 description = "Run a read-only SQL query (SELECT, WITH, EXPLAIN, SHOW, VALUES) against the workspace. For DDL/DML use the execute tool."
2128 )]
2129 fn query(
2130 &self,
2131 Parameters(params): Parameters<QueryParams>,
2132 ) -> Result<CallToolResult, rmcp::ErrorData> {
2133 let result = self.with_engine(|engine| {
2134 if !is_read_only_sql(¶ms.sql) {
2135 return Err(McpError::new(
2136 ErrorCode::SqlError,
2137 "The query tool only accepts read-only SQL (SELECT, WITH, EXPLAIN, SHOW, VALUES). Use the execute tool for DDL/DML.",
2138 ));
2139 }
2140 let target_db = self.resolve_db(engine, params.database.as_deref(), None, false)?;
2143 let _search_guard = match target_db {
2144 Some(ref alias) => Some(engine.scoped_search_path(alias)?),
2145 None => None,
2146 };
2147 const MAX_QUERY_ROWS: usize = 10_000;
2151
2152 let timer = crate::stats::StatsTimer::start();
2153 let mut rows = engine.execute_query_to_json(¶ms.sql)?;
2154 let total_rows = rows.len();
2155 let truncated = total_rows > MAX_QUERY_ROWS;
2156 if truncated {
2157 rows.truncate(MAX_QUERY_ROWS);
2158 }
2159 let elapsed = timer.elapsed_ms();
2160 let stats = crate::stats::QueryStats {
2161 operation: "query".into(),
2162 rows_returned: rows.len() as u64,
2163 rows_scanned: 0,
2164 elapsed_ms: elapsed,
2165 result_size_bytes: serde_json::to_string(&rows).map_or(0, |s| s.len() as u64),
2166 tables_touched: vec![],
2167 };
2168 let payload = if truncated {
2169 json!({
2170 "result": rows,
2171 "stats": stats.to_json(),
2172 "truncated": true,
2173 "total_rows": total_rows,
2174 "rows_returned": MAX_QUERY_ROWS,
2175 "hint": format!(
2176 "Result set has {total_rows} rows; only the first {MAX_QUERY_ROWS} \
2177 are shown. Add a LIMIT clause, aggregate with GROUP BY, or use \
2178 the `export` tool to write the full result to a file."
2179 ),
2180 })
2181 } else {
2182 json!({
2183 "result": rows,
2184 "stats": stats.to_json(),
2185 })
2186 };
2187 Ok((params.sql.clone(), payload))
2188 });
2189
2190 match result {
2191 Ok((sql, val)) => {
2192 let formatted_sql = Self::fmt_sql(&sql);
2193 let json_text = serde_json::to_string_pretty(&val).unwrap_or_default();
2194 Ok(CallToolResult::success(vec![
2195 Content::text(format!("```sql\n{formatted_sql}\n```")),
2196 Content::text(json_text),
2197 ]))
2198 }
2199 Err(e) => Self::err_content(e),
2200 }
2201 }
2202
2203 #[tool(
2205 description = "Execute a DDL/DML statement (CREATE TABLE, INSERT, UPDATE, DELETE, DROP, ALTER, COPY, etc.). Returns affected row count. Disabled in read-only mode."
2206 )]
2207 fn execute(
2208 &self,
2209 Parameters(params): Parameters<ExecuteParams>,
2210 ) -> Result<CallToolResult, rmcp::ErrorData> {
2211 if let Err(e) = self.check_writable("execute") {
2212 return Self::err_content(e);
2213 }
2214 let sql = params.sql.clone();
2215 let result = self.with_engine(|engine| {
2216 if is_read_only_sql(¶ms.sql) {
2217 return Err(McpError::new(
2218 ErrorCode::SqlError,
2219 "The execute tool is for DDL/DML. Use the query tool for SELECT/WITH/EXPLAIN statements.",
2220 ));
2221 }
2222 let target_db = self.resolve_db(engine, params.database.as_deref(), None, true)?;
2225 let _search_guard = match target_db {
2226 Some(ref alias) => Some(engine.scoped_search_path(alias)?),
2227 None => None,
2228 };
2229 let timer = crate::stats::StatsTimer::start();
2230 let affected = engine.execute_command(¶ms.sql)?;
2231 let elapsed = timer.elapsed_ms();
2232 if is_structural_sql(¶ms.sql) {
2246 self.after_execute_catalog_update(engine, target_db.as_deref());
2247 }
2248 Ok(json!({
2249 "sql": Self::fmt_sql(¶ms.sql),
2250 "affected_rows": affected,
2251 "stats": { "operation": "command", "elapsed_ms": elapsed },
2252 }))
2253 });
2254
2255 match result {
2256 Ok(val) => {
2257 self.notify_workspace_changed();
2262 if is_structural_sql(&sql) {
2263 self.notify_resource_list_changed();
2264 }
2265 Self::ok_content(val)
2266 }
2267 Err(e) => Self::err_content(e),
2268 }
2269 }
2270
2271 #[tool(
2273 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."
2274 )]
2275 fn sample(
2276 &self,
2277 Parameters(params): Parameters<SampleParams>,
2278 ) -> Result<CallToolResult, rmcp::ErrorData> {
2279 let result = self.with_engine(|engine| {
2280 let target_db = self.resolve_db(engine, params.database.as_deref(), None, false)?;
2281 let timer = crate::stats::StatsTimer::start();
2282 let n = params.n.unwrap_or(5);
2283 let mut sample = engine.sample_table_in(target_db.as_deref(), ¶ms.table, n)?;
2284 let elapsed = timer.elapsed_ms();
2285 if let Some(obj) = sample.as_object_mut() {
2286 obj.insert(
2287 "stats".into(),
2288 json!({ "operation": "sample", "elapsed_ms": elapsed }),
2289 );
2290 }
2291 Ok(sample)
2292 });
2293
2294 match result {
2295 Ok(val) => Self::ok_content(val),
2296 Err(e) => Self::err_content(e),
2297 }
2298 }
2299
2300 #[tool(
2302 description = "Render a chart (bar, line, scatter, or histogram) from a SQL query. Writes the image to disk by default and returns a short stats blob with the path — use `Read(path)` to display it (this keeps the MCP transcript small). Set `inline=true` to also receive the PNG/SVG bytes inline in the tool result; combine with `output_path` to get both.\n\n**Data shape:** The query must return long-format data with one numeric `y` column. For multi-series charts, use a `series` column to split by category. If your data is wide-format (multiple value columns), reshape it with `UNION ALL` into (label, series, value) tuples before charting.\n\n**DATE/TIMESTAMP x-axis:** Line and scatter charts auto-detect non-numeric x columns. DATE, TIMESTAMP, and TIMESTAMPTZ values render with a **proportional time axis** — gaps between data points reflect real wall-clock time (4.5 h gap and 17 h gap don't look the same). Tick labels are formatted in the input kind: `%Y-%m-%d` for DATE, `%Y-%m-%d %H:%M:%S` for TIMESTAMP, with the originating timezone offset preserved for TIMESTAMPTZ. TEXT x columns fall back to evenly-spaced categorical mode. Set `x_as_category: true` to force categorical layout on temporal data (useful when even spacing reads better than proportional gaps).\n\n- `output_path`: explicit destination file path. Parent directory is created automatically (no need to pre-create it). If omitted, a file is auto-generated under the system temp dir as `hyperdb-charts/chart-<ts>-<pid>-<n>.<ext>`.\n- `inline`: when true, return the image bytes inline. Without `output_path`, suppresses the disk write entirely. With `output_path`, writes to disk AND returns inline. Defaults to false.\n- `format`: \"png\" (default) or \"svg\". Auto-derived from `output_path` extension when omitted. A mismatch between `format` and the path extension returns `INVALID_ARGUMENT`.\n- `overwrite`: default true. Set false to refuse overwriting an existing file (returns `PERMISSION_DENIED`).\n- `x_range` / `y_range`: fix axis extents across multiple charts (e.g. x_range=[0,1500], y_range=[0,1]).\n- `color_map`: stable per-series hex colors (e.g. {\"India\":\"#e41a1c\",\"China\":\"#ff7f0e\"}).\n- `label_points=true`: annotate each point with its series name instead of showing a legend — best when each series has exactly one point."
2303 )]
2304 fn chart(
2305 &self,
2306 Parameters(params): Parameters<ChartParams>,
2307 ) -> Result<CallToolResult, rmcp::ErrorData> {
2308 let result = self.with_engine(|engine| {
2309 if !is_read_only_sql(¶ms.sql) {
2310 return Err(McpError::new(
2311 ErrorCode::SqlError,
2312 "The chart tool only accepts read-only SQL (SELECT, WITH, EXPLAIN, SHOW, VALUES).",
2313 ));
2314 }
2315
2316 if let Some(out) = params.output_path.as_deref() {
2319 crate::attach::validate_output_path(out, "chart output")?;
2320 }
2321 let format = crate::chart::resolve_chart_format(
2324 params.format.as_deref(),
2325 params.output_path.as_deref(),
2326 )?;
2327
2328 let target_db = self.resolve_db(engine, params.database.as_deref(), None, false)?;
2331 let _search_guard = match target_db {
2332 Some(ref alias) => Some(engine.scoped_search_path(alias)?),
2333 None => None,
2334 };
2335
2336 let timer = crate::stats::StatsTimer::start();
2337 let rows = engine.execute_query_to_json(¶ms.sql)?;
2338
2339 let color_map = params
2342 .color_map
2343 .as_ref()
2344 .map(|m| {
2345 m.iter()
2346 .filter_map(|(k, v)| {
2347 crate::chart::parse_hex_color(v)
2348 .map(|c| (k.clone(), c))
2349 })
2350 .collect::<std::collections::HashMap<_, _>>()
2351 })
2352 .unwrap_or_default();
2353
2354 let opts = ChartOptions {
2355 chart_type: ChartType::parse(¶ms.chart_type)?,
2356 x_column: params.x.clone(),
2357 y_column: params.y.clone(),
2358 series_column: params.series.clone(),
2359 title: params.title.clone(),
2360 format,
2361 width: params.width.unwrap_or(800).clamp(200, 4096),
2362 height: params.height.unwrap_or(480).clamp(150, 4096),
2363 bins: params.bins.unwrap_or(20).clamp(1, 500),
2364 x_as_category: params.x_as_category,
2365 x_range: params.x_range,
2366 y_range: params.y_range,
2367 color_map,
2368 label_points: params.label_points.unwrap_or(false),
2369 };
2370
2371 let chart = render_chart(&rows, &opts)?;
2372
2373 let disposition = crate::chart::resolve_chart_disposition(
2377 params.inline.unwrap_or(false),
2378 params.output_path.as_deref(),
2379 opts.format,
2380 );
2381 let overwrite = params.overwrite.unwrap_or(true);
2382 if let Some(path) = disposition.path() {
2383 crate::chart::write_chart_to_disk(path, &chart.bytes, overwrite)?;
2384 }
2385
2386 let elapsed = timer.elapsed_ms();
2387 Ok((chart, elapsed, opts, disposition))
2388 });
2389
2390 match result {
2391 Ok((chart, elapsed_ms, opts, disposition)) => {
2392 let format_str = match opts.format {
2393 ChartFormat::Png => "png",
2394 ChartFormat::Svg => "svg",
2395 };
2396 let wants_inline = disposition.wants_inline();
2397 let output_path_str = disposition.path().map(|p| p.to_string_lossy().into_owned());
2398
2399 let mut stats = serde_json::Map::new();
2400 stats.insert("operation".into(), json!("chart"));
2401 stats.insert("rows_plotted".into(), json!(chart.rows_plotted));
2402 stats.insert("elapsed_ms".into(), json!(elapsed_ms));
2403 stats.insert("format".into(), json!(format_str));
2404 stats.insert("bytes".into(), json!(chart.bytes.len()));
2405 stats.insert("width".into(), json!(opts.width));
2406 stats.insert("height".into(), json!(opts.height));
2407 stats.insert("inline".into(), json!(wants_inline));
2408 if let Some(p) = output_path_str {
2409 stats.insert("output_path".into(), json!(p));
2410 }
2411 let stats_text =
2412 serde_json::to_string_pretty(&Value::Object(stats)).unwrap_or_default();
2413
2414 let mut content = Vec::with_capacity(2);
2415 if wants_inline {
2416 let b64 = base64::engine::general_purpose::STANDARD.encode(&chart.bytes);
2417 content.push(Content::image(b64, chart.mime_type.to_string()));
2418 }
2419 content.push(Content::text(stats_text));
2420 Ok(CallToolResult::success(content))
2421 }
2422 Err(e) => Self::err_content(e),
2423 }
2424 }
2425
2426 #[tool(
2429 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."
2430 )]
2431 fn watch_directory(
2432 &self,
2433 Parameters(params): Parameters<WatchDirectoryParams>,
2434 ) -> Result<CallToolResult, rmcp::ErrorData> {
2435 if let Err(e) = self.check_writable("watch_directory") {
2436 return Self::err_content(e);
2437 }
2438 let canonical = match crate::attach::validate_input_path(¶ms.path, "watch directory") {
2439 Ok(p) => p,
2440 Err(e) => return Self::err_content(e),
2441 };
2442 match self.ensure_engine() {
2445 Ok(guard) => drop(guard),
2446 Err(e) => return Self::err_content(e),
2447 }
2448
2449 let target_db = match self.with_engine(|engine| {
2453 self.resolve_db(engine, params.database.as_deref(), params.persist, true)
2454 }) {
2455 Ok(v) => v,
2456 Err(e) => return Self::err_content(e),
2457 };
2458
2459 let path = canonical;
2460 let engine_handle = self.engine_handle();
2461 let attachments = self.attachments_handle();
2462 let registry = self.watchers_handle();
2463 let options = crate::watcher::WatchOptions {
2464 max_concurrent: params.max_concurrent.unwrap_or(0) as usize,
2465 };
2466 let result = crate::watcher::start_watching(
2467 engine_handle,
2468 attachments,
2469 registry,
2470 Some(self.subscriptions_handle()),
2471 path.clone(),
2472 params.table.clone(),
2473 target_db,
2474 options,
2475 );
2476 match result {
2477 Ok(stats) => {
2478 let body = json!({
2479 "directory": path.to_string_lossy(),
2480 "table": params.table,
2481 "status": "watching",
2482 "max_concurrent": stats.max_concurrent,
2483 "initial_sweep": {
2484 "files_ingested": stats.files_ingested,
2485 "files_failed": stats.files_failed,
2486 },
2487 });
2488 Self::ok_content(body)
2489 }
2490 Err(e) => Self::err_content(e),
2491 }
2492 }
2493
2494 #[tool(
2496 description = "Stop watching a directory previously registered with watch_directory. Pending .ready files are left in place."
2497 )]
2498 fn unwatch_directory(
2499 &self,
2500 Parameters(params): Parameters<UnwatchDirectoryParams>,
2501 ) -> Result<CallToolResult, rmcp::ErrorData> {
2502 let path = std::path::PathBuf::from(¶ms.path);
2503 let result = crate::watcher::stop_watching(&self.watchers_handle(), &path);
2504 match result {
2505 Ok(summary) => Self::ok_content(summary),
2506 Err(e) => Self::err_content(e),
2507 }
2508 }
2509
2510 #[tool(
2513 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."
2514 )]
2515 fn describe(
2516 &self,
2517 Parameters(params): Parameters<DescribeParams>,
2518 ) -> Result<CallToolResult, rmcp::ErrorData> {
2519 let result = self.with_engine(|engine| {
2520 let target_db = self.resolve_db(engine, params.database.as_deref(), None, false)?;
2521 match params.table.as_deref() {
2522 Some(name) => engine
2523 .describe_table_in(target_db.as_deref(), name)
2524 .map(|t| vec![t]),
2525 None => engine.describe_tables_in(target_db.as_deref()),
2526 }
2527 });
2528
2529 match result {
2530 Ok(tables) => Self::ok_content(json!({"tables": tables})),
2531 Err(e) => Self::err_content(e),
2532 }
2533 }
2534
2535 #[tool(
2540 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)."
2541 )]
2542 #[expect(
2543 clippy::unused_self,
2544 reason = "method retained on the type for API symmetry; implementation currently does not need state"
2545 )]
2546 fn inspect_file(
2547 &self,
2548 Parameters(params): Parameters<InspectFileParams>,
2549 ) -> Result<CallToolResult, rmcp::ErrorData> {
2550 if let Err(e) = crate::attach::validate_input_path(¶ms.path, "data file") {
2551 return Self::err_content(e);
2552 }
2553 let sample_rows = params.sample_rows.unwrap_or(5).clamp(1, 50) as usize;
2554 let result = if let Some(ref json_path) = params.json_extract_path {
2555 (|| -> Result<_, McpError> {
2556 let raw = std::fs::read_to_string(¶ms.path).map_err(|e| {
2557 McpError::new(
2558 ErrorCode::FileNotFound,
2559 format!("Cannot read file '{}': {e}", params.path),
2560 )
2561 })?;
2562 let file_size = std::fs::metadata(¶ms.path).map_or(0, |m| m.len());
2563 let extracted = crate::ingest::extract_json_path(&raw, json_path)?;
2564 crate::inspect::inspect_json_from_text(&extracted, file_size, sample_rows)
2565 })()
2566 } else {
2567 crate::inspect::inspect_source(¶ms.path, sample_rows)
2568 };
2569 match result {
2570 Ok(report) => Self::ok_content(report.to_json()),
2571 Err(e) => Self::err_content(e),
2572 }
2573 }
2574
2575 #[tool(
2578 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)."
2579 )]
2580 fn export(
2581 &self,
2582 Parameters(params): Parameters<ExportParams>,
2583 ) -> Result<CallToolResult, rmcp::ErrorData> {
2584 let result = self.with_engine(|engine| {
2585 crate::attach::validate_output_path(¶ms.path, "export")?;
2588 let format_options = match params.format_options.clone() {
2592 None => None,
2593 Some(Value::Object(m)) => Some(m),
2594 Some(other) => {
2595 return Err(McpError::new(
2596 ErrorCode::SchemaMismatch,
2597 format!("export: format_options must be a JSON object, got: {other}"),
2598 ));
2599 }
2600 };
2601 let target_db = self.resolve_db(engine, params.database.as_deref(), None, false)?;
2612 let (effective_sql, effective_table) = match (¶ms.sql, ¶ms.table, &target_db) {
2613 (None, Some(t), Some(db)) => {
2614 let esc_db = db.replace('"', "\"\"");
2615 let esc_tbl = t.replace('"', "\"\"");
2616 (
2617 Some(format!(
2618 "SELECT * FROM \"{esc_db}\".\"public\".\"{esc_tbl}\""
2619 )),
2620 None,
2621 )
2622 }
2623 _ => (params.sql.clone(), params.table.clone()),
2624 };
2625 let _search_guard = match (&effective_sql, &target_db, ¶ms.sql) {
2626 (Some(_), Some(alias), Some(_)) => Some(engine.scoped_search_path(alias)?),
2629 _ => None,
2630 };
2631 let opts = ExportOptions {
2632 sql: effective_sql,
2633 table: effective_table,
2634 path: params.path,
2635 format: params.format,
2636 overwrite: params.overwrite.unwrap_or(true),
2637 format_options,
2638 source_db: target_db.clone(),
2639 };
2640 let export_result = export_to_file(engine, &opts)?;
2641 Ok(json!({
2642 "output_path": export_result.stats.output_path,
2643 "rows": export_result.rows,
2644 "file_size_bytes": export_result.stats.file_size_bytes,
2645 "stats": export_result.stats.to_json(),
2646 }))
2647 });
2648
2649 match result {
2650 Ok(val) => Self::ok_content(val),
2651 Err(e) => Self::err_content(e),
2652 }
2653 }
2654
2655 #[tool(
2659 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."
2660 )]
2661 fn save_query(
2662 &self,
2663 Parameters(params): Parameters<SaveQueryParams>,
2664 ) -> Result<CallToolResult, rmcp::ErrorData> {
2665 if let Err(e) = self.check_writable("save_query") {
2666 return Self::err_content(e);
2667 }
2668 if !is_read_only_sql(¶ms.sql) {
2673 return Self::err_content(McpError::new(
2674 ErrorCode::SqlError,
2675 "save_query only accepts read-only SQL (SELECT / WITH / EXPLAIN / SHOW / VALUES). \
2676 Use the execute tool for DDL/DML, not save_query.",
2677 ));
2678 }
2679 if params.name.is_empty() {
2680 return Self::err_content(McpError::new(
2681 ErrorCode::SchemaMismatch,
2682 "Saved query name must not be empty.",
2683 ));
2684 }
2685 let query = SavedQuery {
2686 name: params.name.clone(),
2687 sql: params.sql,
2688 description: params.description,
2689 created_at: chrono::Utc::now(),
2690 };
2691 let store = Arc::clone(&self.saved_queries);
2692 let result = self.with_saved_query_store(|engine| store.save(engine, query.clone()));
2693 match result {
2694 Ok(()) => {
2695 self.notify_resource_list_changed();
2699 Self::ok_content(json!({
2700 "saved": true,
2701 "name": query.name,
2702 "resources": [
2703 format!("hyper://queries/{}/definition", query.name),
2704 format!("hyper://queries/{}/result", query.name),
2705 ],
2706 "created_at": query.created_at.to_rfc3339(),
2707 }))
2708 }
2709 Err(e) => Self::err_content(e),
2710 }
2711 }
2712
2713 #[tool(
2715 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)."
2716 )]
2717 fn delete_query(
2718 &self,
2719 Parameters(params): Parameters<DeleteQueryParams>,
2720 ) -> Result<CallToolResult, rmcp::ErrorData> {
2721 if let Err(e) = self.check_writable("delete_query") {
2722 return Self::err_content(e);
2723 }
2724 let store = Arc::clone(&self.saved_queries);
2725 let name = params.name.clone();
2726 let result = self.with_saved_query_store(|engine| store.delete(engine, &name));
2727 match result {
2728 Ok(deleted) => {
2729 if deleted {
2730 self.notify_resource_list_changed();
2735 self.subscriptions
2736 .notify_updated(&format!("hyper://queries/{name}/definition"));
2737 self.subscriptions
2738 .notify_updated(&format!("hyper://queries/{name}/result"));
2739 }
2740 Self::ok_content(json!({
2741 "deleted": deleted,
2742 "name": params.name,
2743 }))
2744 }
2745 Err(e) => Self::err_content(e),
2746 }
2747 }
2748
2749 #[tool(
2751 description = "Update prose metadata for a table in the `_table_catalog`: source_url, source_description, purpose, license, notes. Fields you omit stay unchanged; pass an explicit empty string (\"\") to clear a field. Mechanical fields (load_tool, load_params, loaded_at, last_refreshed_at, row_count) are managed by the server. Requires an existing catalog entry — load the table first (load_file / load_data / execute CREATE TABLE) so the stub row is created automatically. Use `database` to target the metadata for a table in a non-primary writable database; read-only attachments are rejected with a clear re-attach-with-writable message. Disabled in read-only mode."
2752 )]
2753 fn set_table_metadata(
2754 &self,
2755 Parameters(params): Parameters<SetTableMetadataParams>,
2756 ) -> Result<CallToolResult, rmcp::ErrorData> {
2757 if let Err(e) = self.check_writable("set_table_metadata") {
2758 return Self::err_content(e);
2759 }
2760 let fields = crate::table_catalog::MetadataFields {
2761 source_url: params.source_url,
2762 source_description: params.source_description,
2763 purpose: params.purpose,
2764 license: params.license,
2765 notes: params.notes,
2766 };
2767 let table_name = params.table.clone();
2768 let result = self.with_engine(|engine| {
2769 let target_db = self.resolve_db(engine, params.database.as_deref(), None, true)?;
2775 crate::table_catalog::set_metadata_in(
2776 engine,
2777 &table_name,
2778 &fields,
2779 target_db.as_deref(),
2780 )
2781 });
2782 match result {
2783 Ok(entry) => Self::ok_content(entry.to_json()),
2784 Err(e) => Self::err_content(e),
2785 }
2786 }
2787
2788 #[tool(
2791 description = "Returns plugin health, workspace info, table count, total rows, disk usage, and active directory watchers."
2792 )]
2793 fn status(&self) -> Result<CallToolResult, rmcp::ErrorData> {
2794 let result = self.with_engine(super::engine::Engine::status);
2795
2796 match result {
2797 Ok(mut val) => {
2798 if let Some(obj) = val.as_object_mut() {
2799 obj.insert("watchers".into(), self.watchers.to_json());
2800 obj.insert("read_only".into(), json!(self.read_only));
2801 let attachments: Vec<Value> = self
2802 .attachments
2803 .list()
2804 .iter()
2805 .map(super::attach::AttachedDb::to_json)
2806 .collect();
2807 obj.insert("attachments".into(), Value::Array(attachments));
2808 }
2809 Self::ok_content(val)
2810 }
2811 Err(e) => Self::err_content(e),
2812 }
2813 }
2814
2815 #[tool(
2820 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."
2821 )]
2822 #[expect(
2823 clippy::unused_self,
2824 reason = "the #[tool] macro dispatches on &self; signature must match the rest of the tool surface even though this tool is stateless"
2825 )]
2826 #[expect(
2827 clippy::unnecessary_wraps,
2828 reason = "uniform Result<CallToolResult, rmcp::ErrorData> across all tools so the #[tool_router] dispatcher has one signature shape"
2829 )]
2830 fn get_readme(&self) -> Result<CallToolResult, rmcp::ErrorData> {
2831 Ok(CallToolResult::success(vec![Content::text(
2832 crate::readme::README,
2833 )]))
2834 }
2835
2836 #[tool(
2839 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."
2840 )]
2841 fn attach_database(
2842 &self,
2843 Parameters(params): Parameters<AttachDatabaseParams>,
2844 ) -> Result<CallToolResult, rmcp::ErrorData> {
2845 let writable = params.writable.unwrap_or(false);
2846 if writable {
2847 if let Err(e) = self.check_writable("attach_database(writable)") {
2848 return Self::err_content(e);
2849 }
2850 }
2851 let on_missing = match attach::OnMissing::parse(params.on_missing.as_deref()) {
2852 Ok(v) => v,
2853 Err(e) => return Self::err_content(e),
2854 };
2855 if on_missing == attach::OnMissing::Create && !writable {
2856 return Self::err_content(McpError::new(
2857 ErrorCode::InvalidArgument,
2858 "on_missing='create' requires writable:true — an empty .hyper file that cannot be written to cannot be populated.",
2859 ));
2860 }
2861 let source = match params.kind.as_str() {
2862 "local_file" => {
2863 let Some(raw) = params.path.as_deref() else {
2864 return Self::err_content(McpError::new(
2865 ErrorCode::InvalidArgument,
2866 "kind='local_file' requires a 'path' argument",
2867 ));
2868 };
2869 let resolved = match on_missing {
2870 attach::OnMissing::Error => attach::validate_local_path(raw),
2871 attach::OnMissing::Create => attach::validate_local_path_for_create(raw),
2872 };
2873 match resolved {
2874 Ok(canonical) => AttachSource::LocalFile { path: canonical },
2875 Err(e) => return Self::err_content(e),
2876 }
2877 }
2878 other => {
2879 return Self::err_content(McpError::new(
2880 ErrorCode::InvalidArgument,
2881 format!(
2882 "Unsupported attach kind '{other}'. Only 'local_file' is supported today; \
2883 'tcp' (remote hyperd) and 'grpc' (Data 360) are planned."
2884 ),
2885 ));
2886 }
2887 };
2888 let req = AttachRequest {
2889 alias: params.alias.clone(),
2890 source,
2891 writable,
2892 on_missing,
2893 };
2894 let registry = self.attachments_handle();
2895 let alias_for_probe = req.alias.clone();
2896 let result = self.with_engine(|engine| {
2897 let entry = registry.attach(engine, req.clone())?;
2898 let tables_visible = probe_table_count(engine, &alias_for_probe);
2903 Ok(json!({
2904 "alias": entry.alias,
2905 "kind": entry.source.kind_str(),
2906 "source": entry.source.to_json(),
2907 "writable": entry.writable,
2908 "tables_visible": tables_visible,
2909 }))
2910 });
2911 match result {
2912 Ok(val) => Self::ok_content(val),
2913 Err(e) => Self::err_content(e),
2914 }
2915 }
2916
2917 #[tool(
2919 description = "Detach a database previously registered with attach_database. No-op when the alias is unknown. Returns {detached: true/false}."
2920 )]
2921 fn detach_database(
2922 &self,
2923 Parameters(params): Parameters<DetachDatabaseParams>,
2924 ) -> Result<CallToolResult, rmcp::ErrorData> {
2925 let alias = params.alias.to_ascii_lowercase();
2930 if let Ok(watchers) = self.watchers.watchers.lock() {
2936 let conflict = watchers
2937 .values()
2938 .find(|h| h.target_db.as_deref() == Some(alias.as_str()));
2939 if let Some(h) = conflict {
2940 return Self::err_content(McpError::new(
2941 ErrorCode::InvalidArgument,
2942 format!(
2943 "cannot detach '{alias}': an active watcher on directory '{}' targets it. \
2944 Call unwatch_directory(\"{}\") first.",
2945 h.directory.display(),
2946 h.directory.display()
2947 ),
2948 ));
2949 }
2950 }
2951 let registry = self.attachments_handle();
2952 let result = self.with_engine(|engine| {
2953 let outcome = registry.detach(engine, &alias)?;
2954 if outcome {
2955 engine.clear_catalog_cache_for(&alias);
2959 }
2960 Ok(outcome)
2961 });
2962 match result {
2963 Ok(detached) => {
2964 Self::ok_content(json!({ "alias": params.alias, "detached": detached }))
2965 }
2966 Err(e) => Self::err_content(e),
2967 }
2968 }
2969
2970 #[tool(
2980 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."
2981 )]
2982 fn list_attached_databases(&self) -> Result<CallToolResult, rmcp::ErrorData> {
2983 let result = self.with_engine(|engine| {
2984 let entries = self.attachments.list();
2985 let attachments: Vec<Value> = entries
2986 .iter()
2987 .map(|entry| {
2988 let mut obj = entry.to_json();
2989 let tables_visible = probe_table_count(engine, &entry.alias);
2990 if let Some(map) = obj.as_object_mut() {
2991 map.insert("tables_visible".into(), json!(tables_visible));
2992 }
2993 obj
2994 })
2995 .collect();
2996 Ok(json!({ "attachments": attachments }))
2997 });
2998 match result {
2999 Ok(val) => Self::ok_content(val),
3000 Err(e) => Self::err_content(e),
3001 }
3002 }
3003
3004 #[tool(
3009 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."
3010 )]
3011 fn copy_query(
3012 &self,
3013 Parameters(params): Parameters<CopyQueryParams>,
3014 ) -> Result<CallToolResult, rmcp::ErrorData> {
3015 if let Err(e) = self.check_writable("copy_query") {
3016 return Self::err_content(e);
3017 }
3018 let mode = match params.mode.as_str() {
3019 "create" | "append" | "replace" => params.mode.clone(),
3020 other => {
3021 return Self::err_content(McpError::new(
3022 ErrorCode::InvalidArgument,
3023 format!(
3024 "copy_query mode '{other}' is not supported. Use 'create', 'append', or 'replace'."
3025 ),
3026 ));
3027 }
3028 };
3029 if !is_read_only_sql(¶ms.sql) {
3030 return Self::err_content(McpError::new(
3031 ErrorCode::SqlError,
3032 "copy_query's `sql` must be a read-only statement (SELECT / WITH / VALUES). \
3033 Use the execute tool for raw DDL/DML.",
3034 ));
3035 }
3036 let target_db_owned = params
3048 .target_database
3049 .as_deref()
3050 .filter(|s| !s.eq_ignore_ascii_case(LOCAL_ALIAS))
3051 .map(str::to_ascii_lowercase);
3052 let target_db = target_db_owned.as_deref();
3053 if let Some(alias) = target_db {
3054 match self.attachments.get(alias) {
3055 None => {
3056 return Self::err_content(McpError::new(
3057 ErrorCode::InvalidArgument,
3058 format!(
3059 "target_database '{alias}' is not attached. Call attach_database first."
3060 ),
3061 ));
3062 }
3063 Some(entry) if !entry.writable => {
3064 return Self::err_content(McpError::new(
3065 ErrorCode::InvalidArgument,
3066 format!(
3067 "target_database '{alias}' was attached read-only. Re-attach with writable:true to use it as a copy target."
3068 ),
3069 ));
3070 }
3071 Some(_) => {}
3072 }
3073 }
3074
3075 let temp_specs = params.temp_attach.clone().unwrap_or_default();
3078 let prepared_temps = match prepare_temp_attachments(&temp_specs, self.is_read_only()) {
3079 Ok(v) => v,
3080 Err(e) => return Self::err_content(e),
3081 };
3082
3083 let target_table = params.target_table.clone();
3084 let sql_body = params.sql.clone();
3085 let load_params = serde_json::to_string(&json!({
3086 "mode": mode,
3087 "target_database": params.target_database,
3088 "target_table": target_table,
3089 "sql": Self::fmt_sql(&sql_body),
3090 }))
3091 .ok();
3092
3093 let registry = self.attachments_handle();
3094 let result = self.with_engine(|engine| {
3095 let mut temp_aliases: Vec<String> = Vec::new();
3097 for req in &prepared_temps {
3098 match registry.attach(engine, req.clone()) {
3099 Ok(entry) => temp_aliases.push(entry.alias),
3100 Err(e) => {
3101 for alias in &temp_aliases {
3103 let _ = registry.detach(engine, alias);
3104 }
3105 return Err(e);
3106 }
3107 }
3108 }
3109
3110 let copy_outcome = perform_copy(engine, &mode, target_db, &target_table, &sql_body);
3113
3114 for alias in &temp_aliases {
3118 if let Err(e) = registry.detach(engine, alias) {
3119 tracing::warn!(
3120 alias = %alias,
3121 err = %e.message,
3122 "failed to detach temp attachment after copy_query",
3123 );
3124 }
3125 }
3126
3127 if copy_outcome.is_ok() && target_db.is_none() {
3138 let row_count = copy_outcome
3139 .as_ref()
3140 .ok()
3141 .and_then(|v| v.get("row_count").and_then(serde_json::Value::as_i64));
3142 self.after_ingest_catalog_update(
3143 engine,
3144 &target_table,
3145 "copy_query",
3146 load_params.as_deref(),
3147 row_count,
3148 target_db,
3149 );
3150 }
3151
3152 copy_outcome
3153 });
3154
3155 match result {
3156 Ok(outcome) => {
3157 if target_db.is_none() {
3159 self.notify_table_changed(&target_table);
3160 }
3161 self.notify_workspace_changed();
3162 if mode != "append" {
3163 self.notify_resource_list_changed();
3166 }
3167 Self::ok_content(outcome)
3168 }
3169 Err(e) => Self::err_content(e),
3170 }
3171 }
3172}
3173
3174#[prompt_router]
3177impl HyperMcpServer {
3178 #[prompt(
3180 name = "analyze-table",
3181 description = "Deep analysis of a single table: schema, sample, column stats, data quality"
3182 )]
3183 pub async fn analyze_table(
3184 &self,
3185 Parameters(args): Parameters<AnalyzeTableArgs>,
3186 ) -> Vec<PromptMessage> {
3187 let context = self.build_analyze_context(&args.table);
3188 vec![
3189 PromptMessage::new_text(
3190 PromptMessageRole::User,
3191 format!(
3192 "Analyze the `{}` table thoroughly.\n\n{}\n\nPlease:\n\
3193 1. Describe each column (what it likely represents based on name and sample values)\n\
3194 2. Compute basic statistics using the query tool: min/max/avg for numeric columns, distinct count and top values for text columns\n\
3195 3. Flag any data quality issues: unexpected NULLs, suspicious outliers, inconsistent formats\n\
3196 4. Summarize your findings in plain English",
3197 args.table, context
3198 ),
3199 ),
3200 PromptMessage::new_text(
3201 PromptMessageRole::Assistant,
3202 format!(
3203 "I'll analyze the `{}` table systematically. Let me start by examining the schema and sample, then run targeted queries for statistics and data quality.",
3204 args.table
3205 ),
3206 ),
3207 ]
3208 }
3209
3210 #[prompt(
3212 name = "compare-tables",
3213 description = "Compare two tables: schema alignment, common keys, JOIN opportunities"
3214 )]
3215 pub async fn compare_tables(
3216 &self,
3217 Parameters(args): Parameters<CompareTablesArgs>,
3218 ) -> Vec<PromptMessage> {
3219 let ctx_a = self.build_brief_context(&args.table_a);
3220 let ctx_b = self.build_brief_context(&args.table_b);
3221 vec![
3222 PromptMessage::new_text(
3223 PromptMessageRole::User,
3224 format!(
3225 "Compare these two tables:\n\n## Table A: `{}`\n{}\n\n## Table B: `{}`\n{}\n\nPlease:\n\
3226 1. Identify columns that appear in both tables (by name or semantic match)\n\
3227 2. Suggest likely JOIN keys and the JOIN type (inner, left, etc.)\n\
3228 3. Highlight schema differences (column types, nullability)\n\
3229 4. Propose 3-5 analytical queries that combine both tables and explain what each reveals",
3230 args.table_a, ctx_a, args.table_b, ctx_b
3231 ),
3232 ),
3233 PromptMessage::new_text(
3234 PromptMessageRole::Assistant,
3235 format!(
3236 "I'll compare `{}` and `{}` systematically — schema alignment first, then join keys, then analytical opportunities.",
3237 args.table_a, args.table_b
3238 ),
3239 ),
3240 ]
3241 }
3242
3243 #[prompt(
3245 name = "data-quality",
3246 description = "Systematic data quality assessment: NULL rates, duplicates, low cardinality, outliers"
3247 )]
3248 pub async fn data_quality(
3249 &self,
3250 Parameters(args): Parameters<DataQualityArgs>,
3251 ) -> Vec<PromptMessage> {
3252 let context = self.build_brief_context(&args.table);
3253 vec![
3254 PromptMessage::new_text(
3255 PromptMessageRole::User,
3256 format!(
3257 "Run a data quality assessment on the `{}` table.\n\n{}\n\nPlease use the query tool to check:\n\
3258 1. NULL rate per column — run SELECT COUNT(*) FILTER (WHERE col IS NULL) / COUNT(*) for each column\n\
3259 2. Duplicate rows — compare COUNT(*) vs COUNT(DISTINCT *) or use GROUP BY\n\
3260 3. Low-cardinality columns — columns with suspiciously few distinct values\n\
3261 4. Numeric outliers — values more than 3 stddev from the mean\n\
3262 5. Date sanity — future dates or impossibly old dates in date/timestamp columns\n\n\
3263 Summarize findings with severity (critical / warning / info) and suggest remediation for each issue.",
3264 args.table, context
3265 ),
3266 ),
3267 PromptMessage::new_text(
3268 PromptMessageRole::Assistant,
3269 format!(
3270 "I'll perform a systematic data quality assessment on `{}`. Let me run targeted queries for each check category.",
3271 args.table
3272 ),
3273 ),
3274 ]
3275 }
3276
3277 #[prompt(
3279 name = "suggest-queries",
3280 description = "Suggest analytical SQL queries for a table, optionally guided by a goal"
3281 )]
3282 pub async fn suggest_queries(
3283 &self,
3284 Parameters(args): Parameters<SuggestQueriesArgs>,
3285 ) -> Vec<PromptMessage> {
3286 let context = self.build_analyze_context(&args.table);
3287 let goal_section = match args.goal.as_deref() {
3288 Some(g) if !g.is_empty() => format!("\n\nSpecific goal: {g}"),
3289 _ => String::new(),
3290 };
3291 vec![
3292 PromptMessage::new_text(
3293 PromptMessageRole::User,
3294 format!(
3295 "Given the `{}` table:\n\n{}{}\n\nSuggest 5 analytical SQL queries that would be useful for exploring this data. \
3296 For each query, provide:\n\
3297 - A descriptive title\n\
3298 - The exact SQL (valid for Hyper / PostgreSQL-compatible syntax)\n\
3299 - One sentence explaining what insight it reveals\n\n\
3300 Prefer queries that use aggregations, GROUP BY, window functions, or CTEs to demonstrate the power of SQL analytics.",
3301 args.table, context, goal_section
3302 ),
3303 ),
3304 PromptMessage::new_text(
3305 PromptMessageRole::Assistant,
3306 format!(
3307 "Based on the schema and sample of `{}`, here are 5 analytical queries.",
3308 args.table
3309 ),
3310 ),
3311 ]
3312 }
3313}
3314
3315#[derive(Debug, Clone)]
3324pub enum ResourceBody {
3325 Json(Value),
3327 Text {
3330 mime_type: String,
3332 content: String,
3334 },
3335}
3336
3337impl ResourceBody {
3338 #[must_use]
3340 pub fn mime_type(&self) -> &str {
3341 match self {
3342 ResourceBody::Json(_) => "application/json",
3343 ResourceBody::Text { mime_type, .. } => mime_type,
3344 }
3345 }
3346
3347 #[must_use]
3350 pub fn to_text(&self) -> String {
3351 match self {
3352 ResourceBody::Json(v) => {
3353 serde_json::to_string_pretty(v).unwrap_or_else(|_| v.to_string())
3354 }
3355 ResourceBody::Text { content, .. } => content.clone(),
3356 }
3357 }
3358
3359 #[must_use]
3362 pub fn as_json(&self) -> Option<&Value> {
3363 match self {
3364 ResourceBody::Json(v) => Some(v),
3365 ResourceBody::Text { .. } => None,
3366 }
3367 }
3368}
3369
3370impl HyperMcpServer {
3371 pub fn resource_body_for_uri(&self, uri: &str) -> Result<Option<ResourceBody>, McpError> {
3390 if uri == "hyper://workspace" {
3391 return self
3392 .with_engine(super::engine::Engine::status)
3393 .map(|v| Some(ResourceBody::Json(v)));
3394 }
3395 if uri == "hyper://tables" {
3396 return self
3397 .with_engine(|engine| {
3398 engine
3399 .describe_tables()
3400 .map(|tables| json!({ "tables": tables }))
3401 })
3402 .map(|v| Some(ResourceBody::Json(v)));
3403 }
3404 if uri == "hyper://readme" {
3405 return self.build_readme_body().map(Some);
3406 }
3407 if let Some(name) = uri
3408 .strip_prefix("hyper://tables/")
3409 .and_then(|rest| rest.strip_suffix("/schema"))
3410 {
3411 let name = name.to_string();
3412 return self
3413 .with_engine(|engine| {
3414 let tables = engine.describe_tables()?;
3415 tables
3416 .into_iter()
3417 .find(|t| t.get("name").and_then(|v| v.as_str()) == Some(name.as_str()))
3418 .ok_or_else(|| {
3419 McpError::new(
3420 ErrorCode::TableNotFound,
3421 format!("Table '{name}' does not exist"),
3422 )
3423 })
3424 })
3425 .map(|v| Some(ResourceBody::Json(v)));
3426 }
3427 if let Some(name) = uri
3428 .strip_prefix("hyper://tables/")
3429 .and_then(|rest| rest.strip_suffix("/sample"))
3430 {
3431 let name = name.to_string();
3432 return self
3433 .with_engine(|engine| engine.sample_table(&name, TABLE_SAMPLE_ROWS))
3434 .map(|v| Some(ResourceBody::Json(v)));
3435 }
3436 if let Some(name) = uri
3437 .strip_prefix("hyper://tables/")
3438 .and_then(|rest| rest.strip_suffix("/csv-sample"))
3439 {
3440 let name = name.to_string();
3441 return self.build_csv_sample_body(&name).map(Some);
3442 }
3443 if let Some(name) = uri
3444 .strip_prefix("hyper://queries/")
3445 .and_then(|rest| rest.strip_suffix("/definition"))
3446 {
3447 return self.build_saved_query_definition(name).map(Some);
3448 }
3449 if let Some(name) = uri
3450 .strip_prefix("hyper://queries/")
3451 .and_then(|rest| rest.strip_suffix("/result"))
3452 {
3453 return self.build_saved_query_result(name).map(Some);
3454 }
3455 Ok(None)
3456 }
3457
3458 fn build_saved_query_definition(&self, name: &str) -> Result<ResourceBody, McpError> {
3462 let store = Arc::clone(&self.saved_queries);
3463 let name = name.to_string();
3464 let query = self.with_saved_query_store(|engine| store.get(engine, &name))?;
3465 match query {
3466 Some(q) => Ok(ResourceBody::Json(q.to_json())),
3467 None => Err(McpError::new(
3468 ErrorCode::TableNotFound,
3469 format!("No saved query named '{name}'"),
3470 )),
3471 }
3472 }
3473
3474 fn build_saved_query_result(&self, name: &str) -> Result<ResourceBody, McpError> {
3479 let store = Arc::clone(&self.saved_queries);
3480 let name_owned = name.to_string();
3481 let query = self
3482 .with_saved_query_store(|engine| store.get(engine, &name_owned))?
3483 .ok_or_else(|| {
3484 McpError::new(
3485 ErrorCode::TableNotFound,
3486 format!("No saved query named '{name_owned}'"),
3487 )
3488 })?;
3489 let sql = query.sql.clone();
3490 let body = self.with_engine(|engine| {
3491 let timer = crate::stats::StatsTimer::start();
3492 let rows = engine.execute_query_to_json(&sql)?;
3493 let elapsed = timer.elapsed_ms();
3494 let result_size = serde_json::to_string(&rows).map_or(0, |s| s.len() as u64);
3495 let stats = crate::stats::QueryStats {
3496 operation: "saved_query".into(),
3497 rows_returned: rows.len() as u64,
3498 rows_scanned: 0,
3499 elapsed_ms: elapsed,
3500 result_size_bytes: result_size,
3501 tables_touched: vec![],
3502 };
3503 Ok(json!({
3504 "name": query.name,
3505 "sql": Self::fmt_sql(&query.sql),
3506 "result": rows,
3507 "stats": stats.to_json(),
3508 }))
3509 })?;
3510 Ok(ResourceBody::Json(body))
3511 }
3512
3513 #[must_use]
3520 pub fn list_resource_uris(&self) -> Vec<String> {
3521 let mut uris = vec![
3522 "hyper://workspace".to_string(),
3523 "hyper://tables".to_string(),
3524 "hyper://readme".to_string(),
3525 ];
3526 if let Ok(tables) = self.with_engine(super::engine::Engine::describe_tables) {
3527 for table in tables {
3531 if let Some(name) = table.get("name").and_then(|v| v.as_str()) {
3532 uris.push(format!("hyper://tables/{name}/schema"));
3533 uris.push(format!("hyper://tables/{name}/sample"));
3534 uris.push(format!("hyper://tables/{name}/csv-sample"));
3535 }
3536 }
3537 }
3538 let store = Arc::clone(&self.saved_queries);
3539 if let Ok(saved) = self.with_saved_query_store(|engine| store.list(engine)) {
3540 for q in saved {
3541 uris.push(format!("hyper://queries/{}/definition", q.name));
3542 uris.push(format!("hyper://queries/{}/result", q.name));
3543 }
3544 }
3545 uris
3546 }
3547
3548 fn build_readme_body(&self) -> Result<ResourceBody, McpError> {
3556 let status = self.with_engine(super::engine::Engine::status)?;
3557 let tables = self
3558 .with_engine(super::engine::Engine::describe_tables)
3559 .unwrap_or_default();
3560
3561 let workspace_mode = status
3562 .get("workspace_mode")
3563 .and_then(|v| v.as_str())
3564 .unwrap_or("unknown");
3565 let workspace_path = status
3566 .get("workspace_path")
3567 .and_then(|v| v.as_str())
3568 .unwrap_or("");
3569 let read_only = status
3570 .get("read_only")
3571 .and_then(serde_json::Value::as_bool)
3572 .unwrap_or(false);
3573 let table_count = tables.len();
3574
3575 let mut md = String::new();
3576 md.push_str("# HyperDB workspace\n\n");
3577 let _ = writeln!(
3578 md,
3579 "- Mode: **{workspace_mode}**{}\n",
3580 if read_only { " (read-only)" } else { "" }
3581 );
3582 if !workspace_path.is_empty() {
3583 let _ = writeln!(md, "- Path: `{workspace_path}`\n");
3584 }
3585 let _ = write!(md, "- Tables: **{table_count}**\n\n");
3586
3587 if tables.is_empty() {
3588 md.push_str(
3589 "_No tables loaded yet._ Use the `load_file` or `load_data` tools to \
3590 ingest CSV / JSON / Parquet / Arrow IPC data; call `inspect_file` \
3591 first if you're unsure of the schema.\n",
3592 );
3593 } else {
3594 md.push_str("## Tables\n\n");
3595 md.push_str("| Table | Rows | Columns |\n");
3596 md.push_str("|---|---:|---|\n");
3597 for t in &tables {
3598 let name = t.get("name").and_then(|v| v.as_str()).unwrap_or("?");
3599 let rows = t
3600 .get("row_count")
3601 .and_then(serde_json::Value::as_i64)
3602 .unwrap_or(0);
3603 let cols: Vec<String> = t
3604 .get("columns")
3605 .and_then(|v| v.as_array())
3606 .map(|arr| {
3607 arr.iter()
3608 .filter_map(|c| {
3609 let n = c.get("name")?.as_str()?;
3610 let ty = c.get("type")?.as_str()?;
3611 Some(format!("`{n}` {ty}"))
3612 })
3613 .collect()
3614 })
3615 .unwrap_or_default();
3616 let _ = writeln!(md, "| `{name}` | {rows} | {} |\n", cols.join(", "));
3617 }
3618 md.push('\n');
3619 md.push_str("## Related resources\n\n");
3620 for t in &tables {
3621 if let Some(name) = t.get("name").and_then(|v| v.as_str()) {
3622 let _ = write!(md, "- `hyper://tables/{name}/schema` — JSON schema and row count\n\
3623 - `hyper://tables/{name}/sample` — first {TABLE_SAMPLE_ROWS} rows as JSON\n\
3624 - `hyper://tables/{name}/csv-sample` — first {TABLE_CSV_SAMPLE_ROWS} rows as CSV\n");
3625 }
3626 }
3627 md.push('\n');
3628 }
3629
3630 md.push_str(
3631 "## Tool hints\n\n\
3632 - `query(sql)` — read-only SQL (SELECT / WITH / EXPLAIN / SHOW / VALUES).\n\
3633 - `execute(sql)` — DDL/DML (disabled in read-only mode).\n\
3634 - `sample(table, n)` — configurable row sample; the fixed-size\n \
3635 `hyper://tables/{name}/sample` resource uses n=5.\n\
3636 - `inspect_file(path)` — dry-run schema inference before loading.\n\
3637 - `chart(sql, chart_type, ...)` — render a PNG/SVG from a query.\n\
3638 - `export(sql|table, path, format)` — write to CSV / Parquet / Arrow IPC / .hyper.\n",
3639 );
3640
3641 Ok(ResourceBody::Text {
3642 mime_type: "text/markdown".into(),
3643 content: md,
3644 })
3645 }
3646
3647 fn build_csv_sample_body(&self, table: &str) -> Result<ResourceBody, McpError> {
3651 let sample =
3652 self.with_engine(|engine| engine.sample_table(table, TABLE_CSV_SAMPLE_ROWS))?;
3653
3654 let header: Vec<String> = sample
3658 .get("schema")
3659 .and_then(|v| v.as_array())
3660 .map(|cols| {
3661 cols.iter()
3662 .filter_map(|c| c.get("name").and_then(|n| n.as_str()).map(String::from))
3663 .collect()
3664 })
3665 .filter(|v: &Vec<String>| !v.is_empty())
3666 .or_else(|| {
3667 sample
3668 .get("rows")
3669 .and_then(|v| v.as_array())
3670 .and_then(|rows| rows.first())
3671 .and_then(|r| r.as_object())
3672 .map(|o| o.keys().cloned().collect())
3673 })
3674 .unwrap_or_default();
3675
3676 let mut wtr = csv::Writer::from_writer(Vec::<u8>::new());
3677 if !header.is_empty() {
3678 wtr.write_record(&header).map_err(|e| {
3679 McpError::new(
3680 ErrorCode::InternalError,
3681 format!("Failed to write CSV header: {e}"),
3682 )
3683 })?;
3684 }
3685 if let Some(rows) = sample.get("rows").and_then(|v| v.as_array()) {
3686 for row in rows {
3687 let record: Vec<String> = header
3688 .iter()
3689 .map(|col| row.get(col).map(value_to_csv_cell).unwrap_or_default())
3690 .collect();
3691 wtr.write_record(&record).map_err(|e| {
3692 McpError::new(
3693 ErrorCode::InternalError,
3694 format!("Failed to write CSV row: {e}"),
3695 )
3696 })?;
3697 }
3698 }
3699 let bytes = wtr.into_inner().map_err(|e| {
3700 McpError::new(
3701 ErrorCode::InternalError,
3702 format!("Failed to finalize CSV: {e}"),
3703 )
3704 })?;
3705 let content = String::from_utf8(bytes).map_err(|e| {
3706 McpError::new(
3707 ErrorCode::InternalError,
3708 format!("CSV produced invalid UTF-8: {e}"),
3709 )
3710 })?;
3711
3712 Ok(ResourceBody::Text {
3713 mime_type: "text/csv".into(),
3714 content,
3715 })
3716 }
3717
3718 fn build_analyze_context(&self, table: &str) -> String {
3721 match self.with_engine(|engine| engine.sample_table(table, 10)) {
3722 Ok(sample) => format!(
3723 "Schema and sample:\n```json\n{}\n```",
3724 serde_json::to_string_pretty(&sample).unwrap_or_else(|_| sample.to_string())
3725 ),
3726 Err(e) => format!("(Could not load table context: {e})"),
3727 }
3728 }
3729
3730 fn build_brief_context(&self, table: &str) -> String {
3732 match self.with_engine(|engine| engine.sample_table(table, 5)) {
3733 Ok(sample) => format!(
3734 "```json\n{}\n```",
3735 serde_json::to_string_pretty(&sample).unwrap_or_else(|_| sample.to_string())
3736 ),
3737 Err(e) => format!("(Could not load table context: {e})"),
3738 }
3739 }
3740}
3741
3742#[tool_handler]
3745#[prompt_handler]
3746impl ServerHandler for HyperMcpServer {
3747 fn get_info(&self) -> ServerInfo {
3748 let sql_dialect = "\n\
3749\n\
3750SQL DIALECT — Salesforce Data Cloud SQL (PostgreSQL-compatible with extensions).\n\
3751Key differences from standard PostgreSQL an LLM should know:\n\
3752\n\
3753TYPES\n\
3754- Supported: SMALLINT, INTEGER/INT, BIGINT, REAL/FLOAT4, DOUBLE PRECISION/FLOAT8,\n\
3755 NUMERIC(p,s)/DECIMAL(p,s), BOOLEAN, TEXT, CHAR(n), VARCHAR(n), BYTES,\n\
3756 DATE, TIME, TIMESTAMP, TIMESTAMPTZ, INTERVAL, and arrays of any atomic type\n\
3757- NUMERIC precision > 18 requires .hyper file format version 3 (default in this MCP)\n\
3758- No SERIAL / BIGSERIAL / UUID / JSON / JSONB / geometry types\n\
3759\n\
3760SELECT / QUERY\n\
3761- LIMIT / OFFSET work as in PostgreSQL; TOP N is also accepted\n\
3762- LATERAL is optional: subqueries in FROM always see preceding FROM items implicitly\n\
3763- DISTINCT ON (expr, ...) is supported\n\
3764- FROM clause is optional (can evaluate expressions without a table)\n\
3765- Function calls may appear directly in the FROM list\n\
3766- information_schema and pg_catalog do NOT exist; use the describe/sample tools\n\
3767\n\
3768GROUP BY / AGGREGATION\n\
3769- GROUPING SETS, ROLLUP, CUBE all supported\n\
3770- GROUP BY DISTINCT removes duplicate grouping sets before processing\n\
3771- FILTER (WHERE ...) clause supported on aggregate calls\n\
3772- Ordered-set aggregates: MODE(), PERCENTILE_CONT(), PERCENTILE_DISC() with WITHIN GROUP (ORDER BY ...)\n\
3773- APPROX_COUNT_DISTINCT() for fast approximate cardinality\n\
3774- GROUPING() function identifies which columns are aggregated in GROUPING SETS\n\
3775\n\
3776WINDOW FUNCTIONS\n\
3777- Standard: row_number, rank, dense_rank, percent_rank, cume_dist, ntile, lag, lead,\n\
3778 first_value, last_value, nth_value\n\
3779- Hyper extension: modified_rank() — like rank() but assigns the LOWEST rank on ties\n\
3780- IGNORE NULLS / RESPECT NULLS supported on last_value only\n\
3781- nth_value supports FROM FIRST / FROM LAST\n\
3782- Frame modes: ROWS, RANGE, GROUPS; EXCLUDE CURRENT ROW / GROUP / TIES / NO OTHERS\n\
3783- Window-specific functions do NOT support DISTINCT or ORDER BY in their argument list\n\
3784\n\
3785SET-RETURNING FUNCTIONS (usable in FROM)\n\
3786- unnest(array) — expands an array to rows; supports WITH ORDINALITY\n\
3787- generate_series(start, stop [, step]) — numeric and datetime variants\n\
3788- external(path, format => '...') — reads Parquet, CSV, Iceberg etc. directly from files\n\
3789\n\
3790SET OPERATORS\n\
3791- UNION, INTERSECT, EXCEPT all supported; INTERSECT binds tighter than UNION/EXCEPT\n\
3792- ORDER BY and LIMIT/OFFSET can appear on parenthesized sub-expressions or the final result\n\
3793\n\
3794CTEs\n\
3795- WITH and WITH RECURSIVE both supported\n\
3796- CTEs evaluate once per query execution even if referenced multiple times\n\
3797\n\
3798IDENTIFIERS\n\
3799- Unquoted identifiers are folded to lowercase; double-quote to preserve case or use special chars\n\
3800- Quote names containing uppercase letters, digits at the start, or special characters\n\
3801\n\
3802NOT AVAILABLE IN HYPER (Data 360 / Data Cloud-only features)\n\
3803- AI functions: AI_CLASSIFY, AI_SENTIMENT, and other Data Cloud AI scalar functions\n\
3804- Data Cloud federation / streaming-specific functions\n\
3805\n\
3806Full SQL reference: https://developer.salesforce.com/docs/data/data-cloud-query-guide/references/dc-sql-reference";
3807
3808 let header = if self.read_only {
3809 "HyperDB MCP (read-only): SQL analytics for LLM workflows. Query existing tables, \
3810 sample data, export results. Mutating operations are disabled. \
3811 Call get_readme for a concise tool index, parameter rules, and usage examples."
3812 } else {
3813 "HyperDB MCP: instant SQL analytics for LLM workflows. Load data (CSV, JSON, Parquet, \
3814 Arrow IPC, Apache Iceberg), query with SQL, export results (Parquet, Iceberg, Arrow IPC, \
3815 CSV, Hyper). Use query for SELECT and execute for DDL/DML. \
3816 Call get_readme for a concise tool index, parameter rules, and usage examples."
3817 };
3818 let instructions = format!("{header}{sql_dialect}");
3819 let mut server_info = Implementation::default();
3820 server_info.name = "HyperDB".into();
3821 server_info.title = Some("HyperDB — Hyper SQL Analytics".into());
3822 server_info.version = env!("CARGO_PKG_VERSION").into();
3823 server_info.description = Some(
3824 "MCP server for Tableau Hyper: instant SQL analytics over \
3825 CSV, JSON, Parquet, Arrow IPC, and Apache Iceberg with schema inference, \
3826 partial schema overrides, full-file numeric widening, and \
3827 dry-run file inspection. SQL dialect is PostgreSQL-compatible with \
3828 extensions (Salesforce Data Cloud SQL). Full SQL reference: \
3829 https://developer.salesforce.com/docs/data/data-cloud-query-guide/references/dc-sql-reference/data-cloud-sql-context.html"
3830 .into(),
3831 );
3832
3833 let mut info = ServerInfo::default();
3834 info.instructions = Some(instructions);
3835 info.server_info = server_info;
3836 info.capabilities = ServerCapabilities::builder()
3837 .enable_tools()
3838 .enable_prompts()
3839 .enable_resources()
3840 .enable_resources_subscribe()
3845 .enable_resources_list_changed()
3846 .build();
3847 info
3848 }
3849
3850 async fn initialize(
3851 &self,
3852 request: InitializeRequestParams,
3853 context: RequestContext<RoleServer>,
3854 ) -> Result<InitializeResult, rmcp::ErrorData> {
3855 let name = &request.client_info.name;
3856 let version = &request.client_info.version;
3857 let label = if version.is_empty() {
3858 name.clone()
3859 } else {
3860 format!("{name} {version}")
3861 };
3862 if let Ok(mut guard) = self.client_name.lock() {
3863 *guard = Some(label);
3864 }
3865 context.peer.set_peer_info(request);
3866 Ok(self.get_info())
3867 }
3868
3869 async fn subscribe(
3878 &self,
3879 request: SubscribeRequestParams,
3880 context: RequestContext<RoleServer>,
3881 ) -> Result<(), rmcp::ErrorData> {
3882 self.subscriptions.subscribe(&request.uri, context.peer);
3883 Ok(())
3884 }
3885
3886 async fn unsubscribe(
3891 &self,
3892 request: UnsubscribeRequestParams,
3893 context: RequestContext<RoleServer>,
3894 ) -> Result<(), rmcp::ErrorData> {
3895 self.subscriptions.unsubscribe(&request.uri, &context.peer);
3896 Ok(())
3897 }
3898
3899 async fn list_resources(
3905 &self,
3906 _request: Option<PaginatedRequestParams>,
3907 _context: RequestContext<RoleServer>,
3908 ) -> Result<ListResourcesResult, rmcp::ErrorData> {
3909 let mut resources = vec![
3910 RawResource {
3911 uri: "hyper://workspace".into(),
3912 name: "Workspace Info".into(),
3913 title: Some("Hyper Workspace".into()),
3914 description: Some("Workspace mode, table count, total rows, disk usage".into()),
3915 mime_type: Some("application/json".into()),
3916 size: None,
3917 icons: None,
3918 meta: None,
3919 }
3920 .no_annotation(),
3921 RawResource {
3922 uri: "hyper://tables".into(),
3923 name: "All Tables".into(),
3924 title: Some("All Tables".into()),
3925 description: Some("List of all tables with column schemas and row counts".into()),
3926 mime_type: Some("application/json".into()),
3927 size: None,
3928 icons: None,
3929 meta: None,
3930 }
3931 .no_annotation(),
3932 RawResource {
3933 uri: "hyper://readme".into(),
3934 name: "Workspace Readme".into(),
3935 title: Some("HyperDB workspace readme".into()),
3936 description: Some(
3937 "Markdown overview of the workspace: tables, row counts, related \
3938 resources, and tool hints for LLMs orienting themselves."
3939 .into(),
3940 ),
3941 mime_type: Some("text/markdown".into()),
3942 size: None,
3943 icons: None,
3944 meta: None,
3945 }
3946 .no_annotation(),
3947 ];
3948
3949 if let Ok(tables) = self.with_engine(super::engine::Engine::describe_tables) {
3950 for table in tables {
3954 if let Some(name) = table.get("name").and_then(|v| v.as_str()) {
3955 let row_count = table
3956 .get("row_count")
3957 .and_then(serde_json::Value::as_i64)
3958 .unwrap_or(0);
3959 resources.push(
3960 RawResource {
3961 uri: format!("hyper://tables/{name}/schema"),
3962 name: format!("Schema of {name}"),
3963 title: Some(format!("{name} schema")),
3964 description: Some(format!(
3965 "Column schema and row count ({row_count} rows) for table '{name}'"
3966 )),
3967 mime_type: Some("application/json".into()),
3968 size: None,
3969 icons: None,
3970 meta: None,
3971 }
3972 .no_annotation(),
3973 );
3974 resources.push(
3975 RawResource {
3976 uri: format!("hyper://tables/{name}/sample"),
3977 name: format!("Sample of {name}"),
3978 title: Some(format!("{name} sample (JSON)")),
3979 description: Some(format!(
3980 "First {TABLE_SAMPLE_ROWS} rows of '{name}' as JSON, with schema"
3981 )),
3982 mime_type: Some("application/json".into()),
3983 size: None,
3984 icons: None,
3985 meta: None,
3986 }
3987 .no_annotation(),
3988 );
3989 resources.push(
3990 RawResource {
3991 uri: format!("hyper://tables/{name}/csv-sample"),
3992 name: format!("CSV sample of {name}"),
3993 title: Some(format!("{name} sample (CSV)")),
3994 description: Some(format!(
3995 "First {TABLE_CSV_SAMPLE_ROWS} rows of '{name}' as CSV"
3996 )),
3997 mime_type: Some("text/csv".into()),
3998 size: None,
3999 icons: None,
4000 meta: None,
4001 }
4002 .no_annotation(),
4003 );
4004 }
4005 }
4006 }
4007
4008 let store = Arc::clone(&self.saved_queries);
4009 if let Ok(saved) = self.with_saved_query_store(|engine| store.list(engine)) {
4010 for q in saved {
4011 let desc = q
4012 .description
4013 .clone()
4014 .unwrap_or_else(|| format!("Saved read-only SQL query '{}'", q.name));
4015 resources.push(
4016 RawResource {
4017 uri: format!("hyper://queries/{}/definition", q.name),
4018 name: format!("Query: {}", q.name),
4019 title: Some(format!("{} (definition)", q.name)),
4020 description: Some(format!("SQL + metadata for saved query '{}'", q.name)),
4021 mime_type: Some("application/json".into()),
4022 size: None,
4023 icons: None,
4024 meta: None,
4025 }
4026 .no_annotation(),
4027 );
4028 resources.push(
4029 RawResource {
4030 uri: format!("hyper://queries/{}/result", q.name),
4031 name: format!("Result: {}", q.name),
4032 title: Some(format!("{} (result)", q.name)),
4033 description: Some(format!("{desc} — re-runs on every read")),
4034 mime_type: Some("application/json".into()),
4035 size: None,
4036 icons: None,
4037 meta: None,
4038 }
4039 .no_annotation(),
4040 );
4041 }
4042 }
4043
4044 Ok(ListResourcesResult {
4045 resources,
4046 next_cursor: None,
4047 meta: None,
4048 })
4049 }
4050
4051 async fn list_resource_templates(
4054 &self,
4055 _request: Option<PaginatedRequestParams>,
4056 _context: RequestContext<RoleServer>,
4057 ) -> Result<ListResourceTemplatesResult, rmcp::ErrorData> {
4058 let templates = vec![
4059 RawResourceTemplate {
4060 uri_template: "hyper://tables/{name}/schema".into(),
4061 name: "Table Schema".into(),
4062 title: Some("Table Schema".into()),
4063 description: Some(
4064 "Column schema, types, nullability, and row count for a named table".into(),
4065 ),
4066 mime_type: Some("application/json".into()),
4067 icons: None,
4068 }
4069 .no_annotation(),
4070 RawResourceTemplate {
4071 uri_template: "hyper://tables/{name}/sample".into(),
4072 name: "Table Sample (JSON)".into(),
4073 title: Some("Table Sample".into()),
4074 description: Some(
4075 "First few rows of a named table as JSON, with schema. For a \
4076 configurable row count use the `sample` tool instead."
4077 .into(),
4078 ),
4079 mime_type: Some("application/json".into()),
4080 icons: None,
4081 }
4082 .no_annotation(),
4083 RawResourceTemplate {
4084 uri_template: "hyper://tables/{name}/csv-sample".into(),
4085 name: "Table Sample (CSV)".into(),
4086 title: Some("Table Sample (CSV)".into()),
4087 description: Some(
4088 "First few rows of a named table as CSV, header-first, for \
4089 spreadsheet and Pandas consumers."
4090 .into(),
4091 ),
4092 mime_type: Some("text/csv".into()),
4093 icons: None,
4094 }
4095 .no_annotation(),
4096 RawResourceTemplate {
4097 uri_template: "hyper://queries/{name}/definition".into(),
4098 name: "Saved Query Definition".into(),
4099 title: Some("Saved Query Definition".into()),
4100 description: Some(
4101 "Stored SQL plus metadata (description, created_at) for a saved \
4102 query registered via the `save_query` tool."
4103 .into(),
4104 ),
4105 mime_type: Some("application/json".into()),
4106 icons: None,
4107 }
4108 .no_annotation(),
4109 RawResourceTemplate {
4110 uri_template: "hyper://queries/{name}/result".into(),
4111 name: "Saved Query Result".into(),
4112 title: Some("Saved Query Result".into()),
4113 description: Some(
4114 "Live result of a saved query. The stored SQL re-runs on every \
4115 resource read — no caching, always fresh."
4116 .into(),
4117 ),
4118 mime_type: Some("application/json".into()),
4119 icons: None,
4120 }
4121 .no_annotation(),
4122 ];
4123 Ok(ListResourceTemplatesResult {
4124 resource_templates: templates,
4125 next_cursor: None,
4126 meta: None,
4127 })
4128 }
4129
4130 async fn read_resource(
4135 &self,
4136 request: ReadResourceRequestParams,
4137 _context: RequestContext<RoleServer>,
4138 ) -> Result<ReadResourceResult, rmcp::ErrorData> {
4139 let uri = &request.uri;
4140 let (mime_type, text) = match self.resource_body_for_uri(uri) {
4141 Ok(Some(body)) => (body.mime_type().to_string(), body.to_text()),
4142 Ok(None) => {
4143 return Err(rmcp::ErrorData::invalid_params(
4144 format!("Unknown resource URI: {uri}"),
4145 None,
4146 ));
4147 }
4148 Err(e) => {
4149 let err_val = serde_json::to_value(&e).unwrap_or(Value::String(e.to_string()));
4152 let text =
4153 serde_json::to_string_pretty(&json!({ "error": err_val })).unwrap_or_default();
4154 ("application/json".into(), text)
4155 }
4156 };
4157
4158 Ok(ReadResourceResult::new(vec![
4159 ResourceContents::TextResourceContents {
4160 uri: uri.clone(),
4161 mime_type: Some(mime_type),
4162 text,
4163 meta: None,
4164 },
4165 ]))
4166 }
4167}
4168
4169fn is_structural_sql(sql: &str) -> bool {
4179 let trimmed = sql.trim_start();
4180 let first: String = trimmed
4181 .chars()
4182 .take_while(|c| c.is_alphabetic())
4183 .flat_map(char::to_uppercase)
4184 .collect();
4185 matches!(
4186 first.as_str(),
4187 "CREATE" | "DROP" | "ALTER" | "TRUNCATE" | "RENAME"
4188 )
4189}
4190
4191fn value_to_csv_cell(v: &Value) -> String {
4197 match v {
4198 Value::Null => String::new(),
4199 Value::Bool(b) => b.to_string(),
4200 Value::Number(n) => n.to_string(),
4201 Value::String(s) => s.clone(),
4202 _ => v.to_string(),
4203 }
4204}
4205
4206fn detect_format(data: &str) -> String {
4209 let trimmed = data.trim_start();
4210 if trimmed.starts_with('[') || trimmed.starts_with('{') {
4211 "json".into()
4212 } else {
4213 "csv".into()
4214 }
4215}
4216
4217fn rand_suffix() -> String {
4221 use std::time::{SystemTime, UNIX_EPOCH};
4222 let t = SystemTime::now()
4223 .duration_since(UNIX_EPOCH)
4224 .unwrap_or_default();
4225 format!("{}", t.as_nanos() % 1_000_000_000)
4226}
4227
4228fn qualified_name(engine: &Engine, db: Option<&str>, table: &str) -> String {
4239 let alias = db.map_or_else(|| engine.primary_db_name(), str::to_string);
4240 let escaped_alias = alias.replace('"', "\"\"");
4241 let escaped_table = table.replace('"', "\"\"");
4242 format!("\"{escaped_alias}\".\"public\".\"{escaped_table}\"")
4243}
4244
4245fn target_exists(engine: &Engine, db: Option<&str>, table: &str) -> Result<bool, McpError> {
4250 let sql = format!(
4251 "SELECT 1 FROM {} LIMIT 0",
4252 qualified_name(engine, db, table)
4253 );
4254 match engine.execute_query_to_json(&sql) {
4255 Ok(_) => Ok(true),
4256 Err(e) => {
4257 let m = e.message.to_lowercase();
4258 let missing = m.contains("does not exist")
4259 || m.contains("undefined table")
4260 || e.message.contains("42P01");
4261 if missing {
4262 Ok(false)
4263 } else {
4264 Err(e)
4265 }
4266 }
4267 }
4268}
4269
4270fn count_rows(engine: &Engine, db: Option<&str>, table: &str) -> i64 {
4275 let sql = format!(
4276 "SELECT COUNT(*) AS cnt FROM {}",
4277 qualified_name(engine, db, table)
4278 );
4279 engine
4280 .execute_query_to_json(&sql)
4281 .ok()
4282 .and_then(|rows| {
4283 rows.first()
4284 .and_then(|r| r.get("cnt").and_then(serde_json::Value::as_i64))
4285 })
4286 .unwrap_or(0)
4287}
4288
4289fn probe_table_count(engine: &Engine, alias: &str) -> Value {
4293 let escaped_alias = alias.replace('"', "\"\"");
4294 let sql = format!(
4295 "SELECT COUNT(*) AS cnt FROM \"{escaped_alias}\".pg_catalog.pg_tables WHERE schemaname = 'public'"
4296 );
4297 match engine.execute_query_to_json(&sql) {
4298 Ok(rows) => rows
4299 .first()
4300 .and_then(|r| r.get("cnt").and_then(serde_json::Value::as_i64))
4301 .map_or(Value::Null, |n| json!(n)),
4302 Err(_) => Value::Null,
4303 }
4304}
4305
4306fn prepare_temp_attachments(
4310 specs: &[AttachSpec],
4311 read_only: bool,
4312) -> Result<Vec<AttachRequest>, McpError> {
4313 let mut out = Vec::with_capacity(specs.len());
4314 for spec in specs {
4315 let writable = spec.writable.unwrap_or(false);
4316 if writable && read_only {
4317 return Err(McpError::new(
4318 ErrorCode::ReadOnlyViolation,
4319 format!(
4320 "temp_attach for alias '{}' requested writable:true but the server is --read-only",
4321 spec.alias
4322 ),
4323 ));
4324 }
4325 let on_missing = attach::OnMissing::parse(spec.on_missing.as_deref())?;
4326 if on_missing == attach::OnMissing::Create && !writable {
4327 return Err(McpError::new(
4328 ErrorCode::InvalidArgument,
4329 format!(
4330 "temp_attach alias '{}' has on_missing='create' but writable is not true — \
4331 an empty .hyper file that cannot be written to cannot be populated.",
4332 spec.alias
4333 ),
4334 ));
4335 }
4336 let source = match spec.kind.as_str() {
4337 "local_file" => {
4338 let Some(raw) = spec.path.as_deref() else {
4339 return Err(McpError::new(
4340 ErrorCode::InvalidArgument,
4341 format!("temp_attach alias '{}' requires a 'path'", spec.alias),
4342 ));
4343 };
4344 let resolved = match on_missing {
4345 attach::OnMissing::Error => attach::validate_local_path(raw)?,
4346 attach::OnMissing::Create => attach::validate_local_path_for_create(raw)?,
4347 };
4348 AttachSource::LocalFile { path: resolved }
4349 }
4350 other => {
4351 return Err(McpError::new(
4352 ErrorCode::InvalidArgument,
4353 format!(
4354 "Unsupported temp_attach kind '{other}' for alias '{}'. Only 'local_file' is supported today.",
4355 spec.alias
4356 ),
4357 ));
4358 }
4359 };
4360 attach::validate_alias(&spec.alias)?;
4361 out.push(AttachRequest {
4362 alias: spec.alias.clone(),
4363 source,
4364 writable,
4365 on_missing,
4366 });
4367 }
4368 Ok(out)
4369}
4370
4371fn perform_copy(
4376 engine: &Engine,
4377 mode: &str,
4378 target_db: Option<&str>,
4379 target_table: &str,
4380 sql_body: &str,
4381) -> Result<Value, McpError> {
4382 let qualified = qualified_name(engine, target_db, target_table);
4383 let exists = target_exists(engine, target_db, target_table)?;
4384 let timer = crate::stats::StatsTimer::start();
4385
4386 match mode {
4387 "create" => {
4388 if exists {
4389 return Err(McpError::new(
4390 ErrorCode::InvalidArgument,
4391 format!(
4392 "Target '{target_table}' already exists. Use mode='append' to add rows or mode='replace' to drop and recreate."
4393 ),
4394 ));
4395 }
4396 engine.execute_command(&format!("CREATE TABLE {qualified} AS {sql_body}"))?;
4397 }
4398 "append" => {
4399 if !exists {
4400 return Err(McpError::new(
4401 ErrorCode::InvalidArgument,
4402 format!(
4403 "Target '{target_table}' does not exist. Use mode='create' to create it from the query or mode='replace' to drop and recreate."
4404 ),
4405 ));
4406 }
4407 engine.execute_command(&format!("INSERT INTO {qualified} {sql_body}"))?;
4408 }
4409 "replace" => {
4410 engine.execute_command(&format!("DROP TABLE IF EXISTS {qualified}"))?;
4419 engine.execute_command(&format!("CREATE TABLE {qualified} AS {sql_body}"))?;
4420 }
4421 other => {
4422 return Err(McpError::new(
4423 ErrorCode::InvalidArgument,
4424 format!("copy_query mode '{other}' is not supported"),
4425 ));
4426 }
4427 }
4428
4429 let elapsed_ms = timer.elapsed_ms();
4430 let row_count = count_rows(engine, target_db, target_table);
4431 Ok(json!({
4432 "target_table": target_table,
4433 "target_database": target_db.unwrap_or(LOCAL_ALIAS),
4434 "mode": mode,
4435 "row_count": row_count,
4436 "stats": { "operation": "copy_query", "elapsed_ms": elapsed_ms },
4437 }))
4438}