1use crate::attach::{self, AttachRegistry, AttachRequest, AttachSource, LOCAL_ALIAS};
15use crate::chart::{render_chart, ChartFormat, ChartOptions, ChartType};
16use crate::engine::{classify_statement, is_read_only_sql, Engine, StatementKind};
17use crate::error::{ErrorCode, McpError};
18use crate::export::{export_to_file, ExportOptions};
19use crate::ingest::{
20 detect_file_format, ingest_csv, ingest_csv_file, ingest_csv_file_async, ingest_json,
21 ingest_json_file, ingest_json_file_async, InferredFileFormat, IngestOptions,
22};
23use crate::ingest_arrow::{
24 ingest_arrow_ipc_file, ingest_arrow_ipc_file_async, ingest_parquet_file,
25 ingest_parquet_file_async,
26};
27use crate::saved_queries::{build_store, SavedQuery, SavedQueryStore};
28use crate::subscriptions::{
29 uris_for_table_change, uris_for_workspace_change, SubscriptionRegistry,
30};
31use base64::Engine as _;
32use rmcp::handler::server::router::prompt::PromptRouter;
33use rmcp::handler::server::router::tool::ToolRouter;
34use rmcp::handler::server::wrapper::Parameters;
35use rmcp::model::{
36 AnnotateAble, CallToolResult, Content, GetPromptRequestParams, GetPromptResult, Implementation,
37 InitializeRequestParams, InitializeResult, ListPromptsResult, ListResourceTemplatesResult,
38 ListResourcesResult, PaginatedRequestParams, PromptMessage, PromptMessageRole, RawResource,
39 RawResourceTemplate, ReadResourceRequestParams, ReadResourceResult, ResourceContents,
40 ServerCapabilities, ServerInfo, SubscribeRequestParams, UnsubscribeRequestParams,
41};
42use rmcp::service::RequestContext;
43use rmcp::{
44 prompt, prompt_handler, prompt_router, tool, tool_handler, tool_router, RoleServer,
45 ServerHandler,
46};
47use schemars::JsonSchema;
48use serde::Deserialize;
49use serde_json::{json, Value};
50use sqlformat::{FormatOptions, Indent, QueryParams as SqlQueryParams};
51use std::fmt::Write as _;
52use std::sync::{Arc, Mutex};
53
54#[expect(
55 unused_imports,
56 reason = "imported for use in doc comments that reference the type path"
57)]
58use rmcp::model::RawTextContent;
59
60const TABLE_SAMPLE_ROWS: u64 = 5;
64
65const TABLE_CSV_SAMPLE_ROWS: u64 = 20;
69
70const KV_SCHEMA_RESOURCE: &str = "\
75KV store backing table (managed by the kv_* tools):
76
77 CREATE TABLE _hyperdb_kv_store (
78 store_name TEXT NOT NULL, -- namespace (the `store` tool argument)
79 key TEXT NOT NULL, -- key within the store
80 value TEXT -- value (nullable); may hold JSON
81 );
82
83There is NO PRIMARY KEY (Hyper disables indexes); (store_name, key) uniqueness is
84enforced by the tool layer's upsert, which is atomic within a single server
85process. Two DIFFERENT server processes writing the same key in a shared
86persistent database concurrently could still create duplicate rows (there is no
87DB-level constraint to catch it). Do not INSERT into this table directly — use
88the kv_* tools, which guarantee uniqueness within a session.
89
90DATABASE / DURABILITY: each database has its own _hyperdb_kv_store table. Every
91kv_* tool takes the same optional `database` parameter as the other tools. Omit it
92and the store lives in the EPHEMERAL database — convenient, but LOST when the
93server restarts. Pass \"persistent\" (or persist=true) to survive restarts, or any
94attached alias to target that database. A store in one database is invisible from
95another.
96
97Enrich an analytical table with KV metadata without ALTER TABLE. The KV table must
98be in the SAME database as the joined table (or fully qualify both) — a LEFT JOIN
99cannot span databases implicitly:
100
101 SELECT t.*, kv.value AS metadata
102 FROM my_table t
103 LEFT JOIN _hyperdb_kv_store kv
104 ON t.id = kv.key AND kv.store_name = 'your_namespace'
105 WHERE t.status = 'active';
106
107ALWAYS include the `kv.store_name = '...'` filter: omitting it fans out any key
108present in multiple stores (row multiplication).
109";
110
111#[derive(Debug, Deserialize, JsonSchema)]
142pub struct QueryDataParams {
143 pub data: String,
145 pub sql: String,
148 pub format: Option<String>,
151 pub table_name: Option<String>,
153 pub schema: Option<Value>,
158}
159
160#[derive(Debug, Deserialize, JsonSchema)]
162pub struct QueryFileParams {
163 pub path: String,
165 pub sql: String,
168 pub table_name: Option<String>,
170 pub schema: Option<Value>,
174 pub json_extract_path: Option<String>,
180}
181
182#[derive(Debug, Deserialize, JsonSchema)]
184pub struct LoadDataParams {
185 pub table: String,
187 pub data: String,
189 pub format: Option<String>,
191 pub mode: Option<String>,
194 pub schema: Option<Value>,
197 pub database: Option<String>,
202 pub persist: Option<bool>,
206}
207
208#[derive(Debug, Deserialize, JsonSchema)]
210pub struct LoadFileParams {
211 pub table: String,
213 pub path: String,
215 pub mode: Option<String>,
220 pub schema: Option<Value>,
227 pub json_extract_path: Option<String>,
233 pub merge_key: Option<MergeKey>,
238 pub database: Option<String>,
242 pub persist: Option<bool>,
245}
246
247#[derive(Debug, JsonSchema)]
257#[schemars(
258 title = "MergeKey",
259 description = "Either a single column name (string) or a list of column names (array of strings)",
260 untagged
261)]
262pub enum MergeKey {
263 Single(String),
264 Multi(Vec<String>),
265}
266
267impl<'de> Deserialize<'de> for MergeKey {
268 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
269 where
270 D: serde::Deserializer<'de>,
271 {
272 use serde::de::Error;
273 let v = serde_json::Value::deserialize(deserializer)?;
274 match v {
275 serde_json::Value::String(s) => Ok(Self::Single(s)),
276 serde_json::Value::Array(arr) => {
277 let mut names = Vec::with_capacity(arr.len());
278 for (i, item) in arr.into_iter().enumerate() {
279 match item {
280 serde_json::Value::String(s) => names.push(s),
281 other => {
282 return Err(D::Error::custom(format!(
283 "merge_key array element [{i}] must be a string \
284 (column name); got {other}"
285 )));
286 }
287 }
288 }
289 Ok(Self::Multi(names))
290 }
291 other => Err(D::Error::custom(format!(
292 "merge_key must be a column name (string) or list of column names \
293 (array of strings); got {other}"
294 ))),
295 }
296 }
297}
298
299impl MergeKey {
300 pub fn into_vec(self) -> Option<Vec<String>> {
304 let v = match self {
305 Self::Single(s) => vec![s],
306 Self::Multi(v) => v,
307 };
308 if v.is_empty() || v.iter().any(String::is_empty) {
309 None
310 } else {
311 Some(v)
312 }
313 }
314}
315
316#[derive(Debug, Deserialize, JsonSchema)]
320pub struct LoadFilesEntry {
321 pub table: String,
323 pub path: String,
325 pub mode: Option<String>,
328 pub schema: Option<Value>,
330 pub json_extract_path: Option<String>,
332 pub merge_key: Option<MergeKey>,
335}
336
337#[derive(Debug, Deserialize, JsonSchema)]
339pub struct LoadFilesParams {
340 pub files: Vec<LoadFilesEntry>,
344 pub concurrency: Option<u32>,
350 pub database: Option<String>,
356 pub persist: Option<bool>,
359}
360
361fn validate_merge_args(
371 mode: &str,
372 merge_key: Option<MergeKey>,
373) -> Result<Option<Vec<String>>, McpError> {
374 match (mode, merge_key) {
375 ("merge", None) => Err(McpError::new(
376 ErrorCode::InvalidArgument,
377 "mode=merge requires merge_key (a column name or list of column names)",
378 )),
379 ("merge", Some(mk)) => mk.into_vec().map(Some).ok_or_else(|| {
380 McpError::new(
381 ErrorCode::InvalidArgument,
382 "merge_key must be a non-empty list of non-empty column names",
383 )
384 }),
385 (_, Some(_)) => Err(McpError::new(
386 ErrorCode::InvalidArgument,
387 "merge_key is only valid with mode=merge",
388 )),
389 (_, None) => Ok(None),
390 }
391}
392
393#[derive(Debug, Deserialize, JsonSchema)]
399pub struct LoadIcebergParams {
400 pub table: String,
402 pub path: String,
405 pub mode: Option<String>,
407 pub metadata_filename: Option<String>,
410 pub version_as_of: Option<i64>,
412}
413
414#[derive(Debug, Deserialize, JsonSchema)]
416pub struct QueryParams {
417 pub sql: String,
419 pub database: Option<String>,
423}
424
425#[derive(Debug, Deserialize, JsonSchema)]
427pub struct ExecuteParams {
428 pub sql: Vec<String>,
446 pub database: Option<String>,
450}
451
452#[derive(Debug, Deserialize, JsonSchema)]
454pub struct SampleParams {
455 pub table: String,
457 pub n: Option<u64>,
459 pub database: Option<String>,
462}
463
464#[derive(Debug, Default, Deserialize, JsonSchema)]
468pub struct DescribeParams {
469 pub table: Option<String>,
472 pub database: Option<String>,
476}
477
478#[derive(Debug, Deserialize, JsonSchema)]
480pub struct ChartParams {
481 pub sql: String,
483 pub chart_type: String,
485 pub x: Option<String>,
487 pub y: Option<String>,
489 pub series: Option<String>,
491 pub title: Option<String>,
493 pub format: Option<String>,
495 pub width: Option<u32>,
497 pub height: Option<u32>,
499 pub bins: Option<u32>,
501 pub x_as_category: Option<bool>,
507 pub x_range: Option<[f64; 2]>,
512 pub y_range: Option<[f64; 2]>,
515 pub color_map: Option<std::collections::HashMap<String, String>>,
520 pub label_points: Option<bool>,
525 pub output_path: Option<String>,
531 pub inline: Option<bool>,
537 pub overwrite: Option<bool>,
541 pub database: Option<String>,
545}
546
547#[derive(Debug, Deserialize, JsonSchema)]
549pub struct WatchDirectoryParams {
550 pub path: String,
552 pub table: String,
554 #[serde(default)]
557 pub max_concurrent: Option<u32>,
558 pub database: Option<String>,
567 pub persist: Option<bool>,
570}
571
572#[derive(Debug, Deserialize, JsonSchema)]
574pub struct UnwatchDirectoryParams {
575 pub path: String,
577}
578
579#[derive(Debug, Deserialize, JsonSchema)]
589pub struct InspectFileParams {
590 pub path: String,
593 pub sample_rows: Option<u32>,
597 pub json_extract_path: Option<String>,
601}
602
603#[derive(Debug, Deserialize, JsonSchema)]
605pub struct ExportParams {
606 pub sql: Option<String>,
608 pub table: Option<String>,
610 pub path: String,
612 pub format: String,
617 pub overwrite: Option<bool>,
621 pub format_options: Option<Value>,
639 pub database: Option<String>,
645}
646
647#[derive(Debug, Deserialize, JsonSchema)]
661pub struct SaveQueryParams {
662 pub name: String,
665 pub sql: String,
669 pub description: Option<String>,
671}
672
673#[derive(Debug, Deserialize, JsonSchema)]
675pub struct DeleteQueryParams {
676 pub name: String,
679}
680
681#[derive(Debug, Deserialize, JsonSchema, Clone)]
686pub struct AttachSpec {
687 pub alias: String,
691 pub kind: String,
695 pub path: Option<String>,
698 pub writable: Option<bool>,
702 pub on_missing: Option<String>,
708}
709
710#[derive(Debug, Deserialize, JsonSchema)]
714pub struct AttachDatabaseParams {
715 pub alias: String,
719 pub kind: String,
721 pub path: Option<String>,
725 pub writable: Option<bool>,
729 pub on_missing: Option<String>,
740}
741
742#[derive(Debug, Deserialize, JsonSchema)]
744pub struct DetachDatabaseParams {
745 pub alias: String,
747}
748
749#[derive(Debug, Deserialize, JsonSchema)]
757pub struct CopyQueryParams {
758 pub sql: String,
763 pub target_table: String,
766 pub mode: String,
774 pub target_database: Option<String>,
778 pub temp_attach: Option<Vec<AttachSpec>>,
782}
783
784#[derive(Debug, Deserialize, JsonSchema)]
792pub struct SetTableMetadataParams {
793 pub table: String,
797 pub source_url: Option<String>,
799 pub source_description: Option<String>,
802 pub purpose: Option<String>,
805 pub license: Option<String>,
807 pub notes: Option<String>,
809 pub data_url: Option<String>,
814 pub database: Option<String>,
822}
823
824#[derive(Debug, Deserialize, JsonSchema)]
826pub struct KvKeyParams {
827 pub store: String,
830 pub key: String,
832 pub database: Option<String>,
837 pub persist: Option<bool>,
841}
842
843#[derive(Debug, Deserialize, JsonSchema)]
845pub struct KvSetParams {
846 pub store: String,
848 pub key: String,
850 pub value: String,
852 pub database: Option<String>,
857 pub persist: Option<bool>,
861}
862
863#[derive(Debug, Deserialize, JsonSchema)]
866pub struct KvStoreParams {
867 pub store: String,
869 pub database: Option<String>,
873 pub persist: Option<bool>,
876}
877
878#[derive(Debug, Deserialize, JsonSchema)]
880pub struct KvListStoresParams {
881 pub database: Option<String>,
885 pub persist: Option<bool>,
888}
889
890#[derive(Debug, Deserialize, JsonSchema)]
894pub struct AnalyzeTableArgs {
895 pub table: String,
897}
898
899#[derive(Debug, Deserialize, JsonSchema)]
901pub struct CompareTablesArgs {
902 pub table_a: String,
904 pub table_b: String,
906}
907
908#[derive(Debug, Deserialize, JsonSchema)]
910pub struct DataQualityArgs {
911 pub table: String,
913}
914
915#[derive(Debug, Deserialize, JsonSchema)]
917pub struct SuggestQueriesArgs {
918 pub table: String,
920 pub goal: Option<String>,
922}
923
924pub struct HyperMcpServer {
933 engine: Arc<Mutex<Option<Engine>>>,
934 catalog_ready: Arc<Mutex<bool>>,
940 watchers: Arc<crate::watcher::WatcherRegistry>,
941 saved_queries: Arc<dyn SavedQueryStore>,
942 subscriptions: Arc<SubscriptionRegistry>,
943 attachments: Arc<AttachRegistry>,
949 workspace_path: Option<String>,
953 read_only: bool,
954 no_daemon: bool,
956 last_heartbeat: std::sync::Mutex<std::time::Instant>,
958 client_name: std::sync::Mutex<Option<String>>,
962 #[expect(dead_code, reason = "constructed for rmcp 1.x macro-based dispatch")]
967 tool_router: ToolRouter<Self>,
968 #[expect(dead_code, reason = "constructed for rmcp 1.x macro-based dispatch")]
969 prompt_router: PromptRouter<Self>,
970}
971
972impl std::fmt::Debug for HyperMcpServer {
973 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
974 f.debug_struct("HyperMcpServer")
975 .field("persistent_path", &self.workspace_path)
976 .field("read_only", &self.read_only)
977 .field("no_daemon", &self.no_daemon)
978 .finish_non_exhaustive()
979 }
980}
981
982impl HyperMcpServer {
983 pub fn new(persistent_path: Option<String>, read_only: bool) -> Self {
1003 Self::with_options(persistent_path, read_only, false)
1004 }
1005
1006 pub fn with_no_daemon(
1008 persistent_path: Option<String>,
1009 read_only: bool,
1010 no_daemon: bool,
1011 ) -> Self {
1012 Self::with_options(persistent_path, read_only, no_daemon)
1013 }
1014
1015 fn with_options(persistent_path: Option<String>, read_only: bool, no_daemon: bool) -> Self {
1016 let saved_queries: Arc<dyn SavedQueryStore> = build_store(persistent_path.as_deref());
1019 Self {
1020 engine: Arc::new(Mutex::new(None)),
1021 catalog_ready: Arc::new(Mutex::new(false)),
1022 watchers: Arc::new(crate::watcher::WatcherRegistry::new()),
1023 saved_queries,
1024 subscriptions: Arc::new(SubscriptionRegistry::new()),
1025 attachments: Arc::new(AttachRegistry::new()),
1030 workspace_path: persistent_path,
1031 read_only,
1032 no_daemon,
1033 last_heartbeat: std::sync::Mutex::new(std::time::Instant::now()),
1034 client_name: std::sync::Mutex::new(None),
1035 tool_router: Self::tool_router(),
1036 prompt_router: Self::prompt_router(),
1037 }
1038 }
1039
1040 #[must_use]
1044 pub fn subscriptions_handle(&self) -> Arc<SubscriptionRegistry> {
1045 Arc::clone(&self.subscriptions)
1046 }
1047
1048 pub(crate) fn notify_table_changed(&self, table: &str) {
1055 for uri in uris_for_table_change(table) {
1056 self.subscriptions.notify_updated(&uri);
1057 }
1058 }
1059
1060 pub(crate) fn notify_workspace_changed(&self) {
1065 for uri in uris_for_workspace_change() {
1066 self.subscriptions.notify_updated(uri);
1067 }
1068 }
1069
1070 pub(crate) fn notify_resource_list_changed(&self) {
1075 self.subscriptions.notify_list_changed();
1076 }
1077
1078 fn client_name(&self) -> Option<String> {
1081 self.client_name.lock().ok().and_then(|g| g.clone())
1082 }
1083
1084 #[must_use]
1087 pub fn engine_handle(&self) -> Arc<Mutex<Option<Engine>>> {
1088 Arc::clone(&self.engine)
1089 }
1090
1091 #[must_use]
1093 pub fn watchers_handle(&self) -> Arc<crate::watcher::WatcherRegistry> {
1094 Arc::clone(&self.watchers)
1095 }
1096
1097 #[must_use]
1100 pub fn attachments_handle(&self) -> Arc<AttachRegistry> {
1101 Arc::clone(&self.attachments)
1102 }
1103
1104 #[must_use]
1106 pub fn is_read_only(&self) -> bool {
1107 self.read_only
1108 }
1109
1110 fn check_writable(&self, operation: &str) -> Result<(), McpError> {
1113 if self.read_only {
1114 Err(McpError::new(
1115 ErrorCode::ReadOnlyViolation,
1116 format!("Operation '{operation}' is not permitted in read-only mode"),
1117 ))
1118 } else {
1119 Ok(())
1120 }
1121 }
1122
1123 fn resolve_db(
1132 &self,
1133 engine: &Engine,
1134 database: Option<&str>,
1135 persist: Option<bool>,
1136 require_writable: bool,
1137 ) -> Result<Option<String>, McpError> {
1138 let effective = match (database, persist) {
1139 (Some(db), _) => Some(db),
1140 (None, Some(true)) => Some(Engine::PERSISTENT_ALIAS),
1141 _ => None,
1142 };
1143 let effective = effective.filter(|s| !s.eq_ignore_ascii_case(crate::attach::LOCAL_ALIAS));
1145
1146 let resolved = engine.resolve_target_db(effective)?;
1147 let primary = engine.primary_db_name();
1148
1149 if resolved == primary {
1150 return Ok(None);
1151 }
1152
1153 if require_writable && resolved != Engine::PERSISTENT_ALIAS {
1154 match self.attachments.get(&resolved) {
1155 None => {
1156 return Err(McpError::new(
1157 ErrorCode::InvalidArgument,
1158 format!(
1159 "database '{resolved}' is not attached. \
1160 Call attach_database first, or use \"persistent\"."
1161 ),
1162 ));
1163 }
1164 Some(entry) if !entry.writable => {
1165 return Err(McpError::new(
1166 ErrorCode::InvalidArgument,
1167 format!(
1168 "database '{resolved}' was attached read-only. \
1169 Re-attach with writable:true to write to it."
1170 ),
1171 ));
1172 }
1173 _ => {}
1174 }
1175 }
1176
1177 Ok(Some(resolved))
1178 }
1179
1180 fn kv_open<'e>(
1189 engine: &'e Engine,
1190 database: Option<&str>,
1191 store: &str,
1192 ) -> Result<hyperdb_api::KvStore<'e>, McpError> {
1193 match database {
1194 Some(alias) => engine.connection().kv_store_in(alias, store),
1195 None => engine.connection().kv_store(store),
1196 }
1197 .map_err(McpError::from)
1198 }
1199
1200 fn ensure_engine(&self) -> Result<std::sync::MutexGuard<'_, Option<Engine>>, McpError> {
1209 let mut guard = self
1210 .engine
1211 .lock()
1212 .map_err(|_| McpError::new(ErrorCode::InternalError, "Lock poisoned"))?;
1213 if guard.is_none() {
1214 tracing::info!(
1215 persistent_db = self.workspace_path.as_deref().unwrap_or("<ephemeral-only>"),
1216 no_daemon = self.no_daemon,
1217 "initializing hyper engine"
1218 );
1219 let engine = if self.no_daemon {
1220 Engine::new_no_daemon(self.workspace_path.clone())?
1221 } else {
1222 Engine::new(self.workspace_path.clone())?
1223 };
1224 tracing::info!(
1225 ephemeral_path = %engine.ephemeral_path().display(),
1226 persistent_path = ?engine.persistent_path(),
1227 log_dir = %engine.log_dir().display(),
1228 "engine ready"
1229 );
1230 if let Err(e) = self.attachments.replay_all(&engine) {
1239 tracing::warn!(err = %e.message, "failed to replay attachments on new engine");
1240 }
1241 *guard = Some(engine);
1242 if let Ok(mut ready) = self.catalog_ready.lock() {
1246 *ready = false;
1247 }
1248 }
1249 Ok(guard)
1250 }
1251
1252 pub fn warm_up_engine(&self) {
1262 match self.ensure_engine() {
1263 Ok(_guard) => {
1264 tracing::info!("engine initialized eagerly at startup");
1265 }
1266 Err(e) => {
1267 tracing::warn!(
1268 err = %e.message,
1269 "eager engine initialization failed at startup; \
1270 will retry on first data-plane tool call"
1271 );
1272 }
1273 }
1274 }
1275
1276 fn ensure_catalog_ready(&self, engine: &Engine) {
1285 if self.read_only {
1286 return;
1287 }
1288 let Ok(mut ready) = self.catalog_ready.lock() else {
1289 return;
1290 };
1291 if *ready {
1292 return;
1293 }
1294 if let Err(e) = crate::table_catalog::ensure_exists(engine) {
1295 tracing::warn!(err = %e.message, "failed to ensure _table_catalog exists");
1296 }
1297 if let Err(e) = crate::table_catalog::reconcile(engine) {
1298 tracing::warn!(err = %e.message, "failed to reconcile _table_catalog on startup");
1299 }
1300 *ready = true;
1301 }
1302
1303 fn after_ingest_catalog_update(
1316 &self,
1317 engine: &Engine,
1318 table_name: &str,
1319 load_tool: &'static str,
1320 load_params: Option<&str>,
1321 row_count: Option<i64>,
1322 target_db: Option<&str>,
1323 ) {
1324 if let Err(e) = crate::table_catalog::upsert_stub_in(
1325 engine,
1326 table_name,
1327 load_tool,
1328 load_params,
1329 row_count,
1330 true,
1331 target_db,
1332 self.client_name().as_deref(),
1333 ) {
1334 tracing::warn!(
1335 table = %table_name,
1336 target_db = ?target_db,
1337 err = %e.message,
1338 "failed to update _table_catalog after ingest"
1339 );
1340 }
1341 }
1342
1343 #[expect(
1353 clippy::unused_self,
1354 reason = "&self required for method-call dispatch; body uses only engine + target_db"
1355 )]
1356 fn after_execute_catalog_update(&self, engine: &Engine, target_db: Option<&str>) {
1357 if let Err(e) = crate::table_catalog::reconcile_in(engine, None) {
1358 tracing::warn!(
1359 err = %e.message,
1360 "failed to reconcile persistent _table_catalog after execute"
1361 );
1362 }
1363 if let Some(alias) = target_db {
1364 if !alias.eq_ignore_ascii_case(Engine::PERSISTENT_ALIAS) {
1365 if let Err(e) = crate::table_catalog::reconcile_in(engine, Some(alias)) {
1366 tracing::warn!(
1367 target_db = alias,
1368 err = %e.message,
1369 "failed to reconcile user-DB _table_catalog after execute"
1370 );
1371 }
1372 }
1373 }
1374 }
1375
1376 fn with_engine<F, R>(&self, f: F) -> Result<R, McpError>
1385 where
1386 F: FnOnce(&Engine) -> Result<R, McpError>,
1387 {
1388 let mut guard = self.ensure_engine()?;
1389 let engine = guard.as_ref().expect("ensure_engine guarantees Some");
1390 self.ensure_catalog_ready(engine);
1395 if !self.no_daemon {
1400 self.maybe_send_heartbeat(engine.daemon_health_port());
1401 }
1402 let result = f(engine);
1403 if let Err(e) = &result {
1404 tracing::debug!(code = ?e.code, message = %e.message, "tool call returned error");
1405 if e.code == ErrorCode::ConnectionLost {
1406 tracing::warn!(
1407 "connection to hyperd lost or desynchronized ({}); \
1412 dropping engine so next call reconnects",
1413 e.message
1414 );
1415 *guard = None;
1416 if let Ok(mut ready) = self.catalog_ready.lock() {
1419 *ready = false;
1420 }
1421 if !self.no_daemon {
1425 crate::daemon::health::report_hyperd_error_to_daemon();
1426 }
1427 }
1428 }
1429 result
1430 }
1431
1432 fn maybe_send_heartbeat(&self, daemon_health_port: Option<u16>) {
1440 const HEARTBEAT_INTERVAL: std::time::Duration = std::time::Duration::from_secs(60);
1441 let should_send = self
1442 .last_heartbeat
1443 .lock()
1444 .is_ok_and(|guard| guard.elapsed() >= HEARTBEAT_INTERVAL);
1445 if should_send {
1446 if let Some(port) = daemon_health_port {
1447 let _ = crate::daemon::health::send_command(port, "HEARTBEAT");
1448 if let Ok(mut guard) = self.last_heartbeat.lock() {
1449 *guard = std::time::Instant::now();
1450 }
1451 }
1452 }
1453 }
1454
1455 fn status_degraded(&self) -> Value {
1468 let (hyperd_running, engine_block) = if let Some(info) =
1473 crate::daemon::discovery::discover()
1474 {
1475 (
1476 true,
1477 json!({
1478 "mode": "daemon",
1479 "hyperd_endpoint": info.hyperd_endpoint,
1480 "daemon_health_port": info.health_port,
1481 }),
1482 )
1483 } else if self.no_daemon {
1484 (
1486 false,
1487 json!({ "mode": "local", "hyperd_endpoint": null, "daemon_health_port": null }),
1488 )
1489 } else {
1490 (
1491 false,
1492 json!({ "mode": "daemon", "hyperd_endpoint": null, "daemon_health_port": null }),
1493 )
1494 };
1495
1496 let persistent_path = self
1497 .workspace_path
1498 .as_ref()
1499 .map_or(Value::Null, |p| Value::String(p.clone()));
1500
1501 let attachments: Vec<Value> = self
1502 .attachments
1503 .list()
1504 .iter()
1505 .map(super::attach::AttachedDb::to_json)
1506 .collect();
1507
1508 json!({
1509 "engine_busy": true,
1510 "hyperd_running": hyperd_running,
1511 "persistent_path": persistent_path,
1512 "has_persistent": self.workspace_path.is_some(),
1513 "engine": engine_block,
1514 "hyper_rust_api_version": crate::version::mcp_version_string(),
1515 "watchers": self.watchers.to_json(),
1516 "read_only": self.read_only,
1517 "attachments": attachments,
1518 })
1519 }
1520
1521 fn with_saved_query_store<F, R>(&self, f: F) -> Result<R, McpError>
1531 where
1532 F: FnOnce(Option<&Engine>) -> Result<R, McpError>,
1533 {
1534 if self.workspace_path.is_some() {
1535 self.with_engine(|engine| f(Some(engine)))
1536 } else {
1537 f(None)
1538 }
1539 }
1540
1541 #[expect(
1542 clippy::unnecessary_wraps,
1543 reason = "signature retained for API symmetry / future fallibility; returning Result/Option keeps callers from breaking when the function later grows failure cases"
1544 )]
1545 fn ok_content(val: Value) -> Result<CallToolResult, rmcp::ErrorData> {
1550 let text = serde_json::to_string_pretty(&val).unwrap_or_default();
1551 let mut result = CallToolResult::structured(val);
1552 result.content = vec![Content::text(text)];
1556 Ok(result)
1557 }
1558
1559 fn fmt_sql(sql: &str) -> String {
1562 let opts = FormatOptions {
1563 indent: Indent::Spaces(2),
1564 uppercase: Some(true),
1565 lines_between_queries: 1,
1566 ..FormatOptions::default()
1567 };
1568 let formatted = sqlformat::format(sql, &SqlQueryParams::None, &opts);
1569 if formatted.trim().is_empty() {
1570 sql.to_owned()
1571 } else {
1572 formatted
1573 }
1574 }
1575
1576 #[expect(
1577 clippy::unnecessary_wraps,
1578 reason = "signature retained for API symmetry / future fallibility; returning Result/Option keeps callers from breaking when the function later grows failure cases"
1579 )]
1580 #[expect(
1581 clippy::needless_pass_by_value,
1582 reason = "call-site ergonomics: function consumes logically-owned parameters, refactoring signatures is not worth per-site churn"
1583 )]
1584 fn err_content(e: McpError) -> Result<CallToolResult, rmcp::ErrorData> {
1589 let err_val = serde_json::to_value(&e).unwrap_or(Value::String(e.to_string()));
1590 let body = json!({"error": err_val});
1591 let text = serde_json::to_string_pretty(&body).unwrap_or_default();
1592 let mut result = CallToolResult::structured_error(body);
1593 result.content = vec![Content::text(text)];
1594 Ok(result)
1595 }
1596}
1597
1598#[tool_router]
1599impl HyperMcpServer {
1600 #[tool(
1602 description = "Ingest inline data (JSON or CSV) and run a SQL query in one call. Creates a temp table, queries, discards."
1603 )]
1604 fn query_data(
1605 &self,
1606 Parameters(params): Parameters<QueryDataParams>,
1607 ) -> Result<CallToolResult, rmcp::ErrorData> {
1608 let result = self.with_engine(|engine| {
1609 let tname = params.table_name.unwrap_or_else(|| "data".into());
1610 let temp_table = format!("_tmp_{}_{}", tname, rand_suffix());
1611 let fmt = params.format.unwrap_or_else(|| detect_format(¶ms.data));
1612 let schema_override = crate::schema::normalize_schema_param(params.schema.as_ref())?;
1613 let opts = IngestOptions {
1614 table: temp_table.clone(),
1615 mode: "replace".into(),
1616 schema_override,
1617 merge_key: None,
1618 target_db: None,
1619 };
1620
1621 let ingest_result = match fmt.as_str() {
1622 "csv" => ingest_csv(engine, ¶ms.data, &opts),
1623 _ => ingest_json(engine, ¶ms.data, &opts),
1624 }?;
1625
1626 let query_sql = params.sql.replace(&tname, &temp_table);
1627 let rows = engine.execute_query_to_json(&query_sql)?;
1628 let _ = engine.execute_command(&format!("DROP TABLE IF EXISTS \"{temp_table}\""));
1629
1630 Ok(json!({
1631 "sql": Self::fmt_sql(¶ms.sql),
1632 "result": rows,
1633 "stats": ingest_result.stats.to_json(),
1634 }))
1635 });
1636
1637 match result {
1638 Ok(val) => Self::ok_content(val),
1639 Err(e) => Self::err_content(e),
1640 }
1641 }
1642
1643 #[tool(
1645 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."
1646 )]
1647 fn query_file(
1648 &self,
1649 Parameters(params): Parameters<QueryFileParams>,
1650 ) -> Result<CallToolResult, rmcp::ErrorData> {
1651 let result = self.with_engine(|engine| {
1652 crate::attach::validate_input_path(¶ms.path, "data file")?;
1653 let stem = std::path::Path::new(¶ms.path)
1654 .file_stem()
1655 .and_then(|s| s.to_str())
1656 .unwrap_or("file")
1657 .to_string();
1658 let tname = params.table_name.unwrap_or_else(|| stem.clone());
1659 let temp_table = format!("_tmp_{}_{}", tname, rand_suffix());
1660 let schema_override = crate::schema::normalize_schema_param(params.schema.as_ref())?;
1661 let opts = IngestOptions {
1662 table: temp_table.clone(),
1663 mode: "replace".into(),
1664 schema_override,
1665 merge_key: None,
1666 target_db: None,
1667 };
1668
1669 let ingest_result = if let Some(ref json_path) = params.json_extract_path {
1670 let raw = std::fs::read_to_string(¶ms.path).map_err(|e| {
1671 McpError::new(
1672 ErrorCode::FileNotFound,
1673 format!("Cannot read file '{}': {e}", params.path),
1674 )
1675 })?;
1676 let extracted = crate::ingest::extract_json_path(&raw, json_path)?;
1677 let array_text = crate::ingest::normalize_json_or_jsonl(&extracted)?;
1678 let mut result = ingest_json(engine, &array_text, &opts)?;
1679 result.stats.operation = "query_file".into();
1680 result.stats.bytes_read = std::fs::metadata(¶ms.path).map_or(0, |m| m.len());
1681 result.stats.file_format = Some("json".into());
1682 result
1683 } else {
1684 match detect_file_format(std::path::Path::new(¶ms.path)) {
1685 InferredFileFormat::Parquet => ingest_parquet_file(engine, ¶ms.path, &opts),
1686 InferredFileFormat::ArrowIpc => {
1687 ingest_arrow_ipc_file(engine, ¶ms.path, &opts)
1688 }
1689 InferredFileFormat::Json => ingest_json_file(engine, ¶ms.path, &opts),
1690 InferredFileFormat::Csv => ingest_csv_file(engine, ¶ms.path, &opts),
1691 }?
1692 };
1693
1694 let query_sql = params.sql.replace(&tname, &temp_table);
1695 let rows = engine.execute_query_to_json(&query_sql)?;
1696 let _ = engine.execute_command(&format!("DROP TABLE IF EXISTS \"{temp_table}\""));
1697
1698 Ok(json!({
1699 "sql": Self::fmt_sql(¶ms.sql),
1700 "result": rows,
1701 "stats": ingest_result.stats.to_json(),
1702 }))
1703 });
1704
1705 match result {
1706 Ok(val) => Self::ok_content(val),
1707 Err(e) => Self::err_content(e),
1708 }
1709 }
1710
1711 #[tool(
1713 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))."
1714 )]
1715 fn load_data(
1716 &self,
1717 Parameters(params): Parameters<LoadDataParams>,
1718 ) -> Result<CallToolResult, rmcp::ErrorData> {
1719 if let Err(e) = self.check_writable("load_data") {
1720 return Self::err_content(e);
1721 }
1722 let table_name = params.table.clone();
1723 let mode = params.mode.clone().unwrap_or_else(|| "replace".into());
1728 let result = self.with_engine(|engine| {
1729 let target_db =
1730 self.resolve_db(engine, params.database.as_deref(), params.persist, true)?;
1731 let fmt = params.format.unwrap_or_else(|| detect_format(¶ms.data));
1732 let schema_override = crate::schema::normalize_schema_param(params.schema.as_ref())?;
1733 let opts = IngestOptions {
1734 table: params.table.clone(),
1735 mode: mode.clone(),
1736 schema_override,
1737 merge_key: None,
1738 target_db: target_db.clone(),
1739 };
1740
1741 let ingest_result = match fmt.as_str() {
1742 "csv" => ingest_csv(engine, ¶ms.data, &opts),
1743 _ => ingest_json(engine, ¶ms.data, &opts),
1744 }?;
1745
1746 let schema_json: Vec<Value> = ingest_result
1747 .schema
1748 .iter()
1749 .map(|c| {
1750 json!({
1751 "name": c.name,
1752 "type": c.hyper_type,
1753 "nullable": c.nullable,
1754 })
1755 })
1756 .collect();
1757
1758 {
1764 let load_params = serde_json::to_string(&json!({
1765 "mode": mode,
1766 "format": fmt,
1767 "database": target_db.as_deref().unwrap_or("local"),
1768 }))
1769 .ok();
1770 self.after_ingest_catalog_update(
1771 engine,
1772 ¶ms.table,
1773 "load_data",
1774 load_params.as_deref(),
1775 i64::try_from(ingest_result.rows).ok(),
1776 target_db.as_deref(),
1777 );
1778 }
1779
1780 Ok(json!({
1781 "rows": ingest_result.rows,
1782 "schema": schema_json,
1783 "stats": ingest_result.stats.to_json(),
1784 }))
1785 });
1786
1787 match result {
1788 Ok(val) => {
1789 self.notify_table_changed(&table_name);
1790 if mode == "replace" {
1791 self.notify_resource_list_changed();
1795 }
1796 Self::ok_content(val)
1797 }
1798 Err(e) => Self::err_content(e),
1799 }
1800 }
1801
1802 #[tool(
1804 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."
1805 )]
1806 fn load_file(
1807 &self,
1808 Parameters(params): Parameters<LoadFileParams>,
1809 ) -> Result<CallToolResult, rmcp::ErrorData> {
1810 if let Err(e) = self.check_writable("load_file") {
1811 return Self::err_content(e);
1812 }
1813 let table_name = params.table.clone();
1814 let mode = params.mode.clone().unwrap_or_else(|| "replace".into());
1815 let merge_key_vec = match validate_merge_args(&mode, params.merge_key) {
1819 Ok(v) => v,
1820 Err(e) => return Self::err_content(e),
1821 };
1822 let result = self.with_engine(|engine| {
1827 let target_db =
1828 self.resolve_db(engine, params.database.as_deref(), params.persist, true)?;
1829 crate::attach::validate_input_path(¶ms.path, "data file")?;
1830 let schema_override = crate::schema::normalize_schema_param(params.schema.as_ref())?;
1831 let opts = IngestOptions {
1832 table: params.table.clone(),
1833 mode: mode.clone(),
1834 schema_override,
1835 merge_key: merge_key_vec.clone(),
1836 target_db: target_db.clone(),
1837 };
1838
1839 let ingest_result = if let Some(ref json_path) = params.json_extract_path {
1840 let raw = std::fs::read_to_string(¶ms.path).map_err(|e| {
1841 McpError::new(
1842 ErrorCode::FileNotFound,
1843 format!("Cannot read file '{}': {e}", params.path),
1844 )
1845 })?;
1846 let extracted = crate::ingest::extract_json_path(&raw, json_path)?;
1847 let array_text = crate::ingest::normalize_json_or_jsonl(&extracted)?;
1848 let mut result = ingest_json(engine, &array_text, &opts)?;
1849 result.stats.operation = "load_file".into();
1850 result.stats.bytes_read = std::fs::metadata(¶ms.path).map_or(0, |m| m.len());
1851 result.stats.file_format = Some("json".into());
1852 result
1853 } else {
1854 match detect_file_format(std::path::Path::new(¶ms.path)) {
1855 InferredFileFormat::Parquet => ingest_parquet_file(engine, ¶ms.path, &opts),
1856 InferredFileFormat::ArrowIpc => {
1857 ingest_arrow_ipc_file(engine, ¶ms.path, &opts)
1858 }
1859 InferredFileFormat::Json => ingest_json_file(engine, ¶ms.path, &opts),
1860 InferredFileFormat::Csv => ingest_csv_file(engine, ¶ms.path, &opts),
1861 }?
1862 };
1863
1864 let schema_changed = ingest_result.stats.schema_changed;
1868
1869 let schema_json: Vec<Value> = ingest_result
1870 .schema
1871 .iter()
1872 .map(|c| {
1873 json!({
1874 "name": c.name,
1875 "type": c.hyper_type,
1876 "nullable": c.nullable,
1877 })
1878 })
1879 .collect();
1880
1881 {
1883 let load_params = serde_json::to_string(&json!({
1884 "source_path": params.path,
1885 "mode": mode,
1886 "schema": params.schema,
1887 "json_extract_path": params.json_extract_path,
1888 "merge_key": merge_key_vec,
1889 "database": target_db.as_deref().unwrap_or("local"),
1890 }))
1891 .ok();
1892 self.after_ingest_catalog_update(
1893 engine,
1894 ¶ms.table,
1895 "load_file",
1896 load_params.as_deref(),
1897 i64::try_from(ingest_result.rows).ok(),
1898 target_db.as_deref(),
1899 );
1900 }
1901
1902 Ok((
1903 json!({
1904 "rows": ingest_result.rows,
1905 "schema": schema_json,
1906 "stats": ingest_result.stats.to_json(),
1907 }),
1908 schema_changed,
1909 ))
1910 });
1911
1912 match result {
1913 Ok((val, schema_changed)) => {
1914 self.notify_table_changed(&table_name);
1915 if mode == "replace" || schema_changed {
1923 self.notify_resource_list_changed();
1924 }
1925 Self::ok_content(val)
1926 }
1927 Err(e) => Self::err_content(e),
1928 }
1929 }
1930
1931 #[tool(
1935 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.**"
1936 )]
1937 fn load_files(
1938 &self,
1939 Parameters(params): Parameters<LoadFilesParams>,
1940 ) -> Result<CallToolResult, rmcp::ErrorData> {
1941 use hyperdb_api::pool::{create_pool, PoolConfig};
1942 use hyperdb_api::CreateMode;
1943
1944 if let Err(e) = self.check_writable("load_files") {
1945 return Self::err_content(e);
1946 }
1947 if params.files.is_empty() {
1948 return Self::err_content(McpError::new(
1949 ErrorCode::EmptyData,
1950 "load_files: `files` must not be empty",
1951 ));
1952 }
1953
1954 for (idx, entry) in params.files.iter().enumerate() {
1961 if let Err(mut e) = crate::attach::validate_input_path(&entry.path, "data file") {
1962 e.message = format!("entry {idx} (table '{}'): {}", entry.table, e.message);
1963 return Self::err_content(e);
1964 }
1965 let mode = entry.mode.as_deref().unwrap_or("replace");
1966 if mode == "merge" || entry.merge_key.is_some() {
1967 return Self::err_content(McpError::new(
1968 ErrorCode::InvalidArgument,
1969 format!(
1970 "load_files does not support mode=merge yet (entry {idx}, table \
1971 '{}'). Call load_file once per file when you need merge semantics.",
1972 entry.table
1973 ),
1974 ));
1975 }
1976 }
1977
1978 let (endpoint, workspace, target_db) = match self.with_engine(|engine| {
1984 let target_db =
1985 self.resolve_db(engine, params.database.as_deref(), params.persist, true)?;
1986 let endpoint = engine.hyperd_endpoint()?;
1987 let workspace = match target_db.as_deref() {
1988 None => engine.ephemeral_path().to_string_lossy().to_string(),
1989 Some(alias) if alias.eq_ignore_ascii_case(Engine::PERSISTENT_ALIAS) => engine
1990 .persistent_path()
1991 .ok_or_else(|| {
1992 McpError::new(
1993 ErrorCode::InvalidArgument,
1994 "target 'persistent' but the server is in --ephemeral-only mode",
1995 )
1996 })?
1997 .to_string_lossy()
1998 .to_string(),
1999 Some(alias) => {
2000 let entry = self.attachments.get(alias).ok_or_else(|| {
2001 McpError::new(
2002 ErrorCode::InvalidArgument,
2003 format!("database '{alias}' is not attached"),
2004 )
2005 })?;
2006 let crate::attach::AttachSource::LocalFile { path } = &entry.source;
2007 path.to_string_lossy().to_string()
2008 }
2009 };
2010 Ok((endpoint, workspace, target_db))
2011 }) {
2012 Ok(v) => v,
2013 Err(e) => return Self::err_content(e),
2014 };
2015
2016 let file_count = params.files.len();
2019 let concurrency = params
2020 .concurrency
2021 .map_or(8, |n| n as usize)
2022 .min(file_count)
2023 .clamp(1, 16);
2024
2025 let pool = match create_pool(
2026 PoolConfig::new(endpoint, workspace)
2027 .create_mode(CreateMode::DoNotCreate)
2028 .max_size(concurrency),
2029 ) {
2030 Ok(p) => Arc::new(p),
2031 Err(e) => {
2032 return Self::err_content(McpError::new(
2033 ErrorCode::InternalError,
2034 format!("Failed to build connection pool for load_files: {e}"),
2035 ))
2036 }
2037 };
2038
2039 let Ok(rt) = tokio::runtime::Handle::try_current() else {
2042 return Self::err_content(McpError::new(
2043 ErrorCode::InternalError,
2044 "load_files must run inside a tokio runtime",
2045 ));
2046 };
2047
2048 #[derive(Default)]
2051 struct EntryOutcome {
2052 table: String,
2053 ok: Option<(u64, Vec<Value>, Value)>,
2054 err: Option<(ErrorCode, String)>,
2055 replace_mode: bool,
2056 }
2057
2058 let outcomes: Vec<EntryOutcome> = tokio::task::block_in_place(|| {
2059 rt.block_on(async {
2060 let mut set = tokio::task::JoinSet::new();
2061 for (idx, entry) in params.files.into_iter().enumerate() {
2062 let pool = Arc::clone(&pool);
2063 let entry_target_db = target_db.clone();
2064 set.spawn(async move {
2065 let mode = entry.mode.clone().unwrap_or_else(|| "replace".into());
2066 let replace_mode = mode == "replace";
2067 let mut out = EntryOutcome {
2068 table: entry.table.clone(),
2069 replace_mode,
2070 ..Default::default()
2071 };
2072
2073 let schema_override =
2078 match crate::schema::normalize_schema_param(entry.schema.as_ref()) {
2079 Ok(v) => v,
2080 Err(e) => {
2081 out.err = Some((e.code, e.message));
2082 return (idx, out);
2083 }
2084 };
2085 let _ = &entry_target_db;
2094 let opts = IngestOptions {
2095 table: entry.table.clone(),
2096 mode: mode.clone(),
2097 schema_override,
2098 merge_key: None,
2099 target_db: None,
2100 };
2101
2102 let mut conn = match pool.get().await {
2105 Ok(c) => c,
2106 Err(e) => {
2107 out.err = Some((
2108 ErrorCode::InternalError,
2109 format!("Failed to check out connection: {e}"),
2110 ));
2111 return (idx, out);
2112 }
2113 };
2114
2115 let ingest_res = if let Some(ref json_path) = entry.json_extract_path {
2120 let raw = match std::fs::read_to_string(&entry.path) {
2121 Ok(s) => s,
2122 Err(e) => {
2123 out.err = Some((
2124 ErrorCode::FileNotFound,
2125 format!("Cannot read file '{}': {e}", entry.path),
2126 ));
2127 return (idx, out);
2128 }
2129 };
2130 let extracted = match crate::ingest::extract_json_path(&raw, json_path)
2131 {
2132 Ok(v) => v,
2133 Err(e) => {
2134 out.err = Some((e.code, e.message));
2135 return (idx, out);
2136 }
2137 };
2138 let array_text =
2139 match crate::ingest::normalize_json_or_jsonl(&extracted) {
2140 Ok(v) => v,
2141 Err(e) => {
2142 out.err = Some((e.code, e.message));
2143 return (idx, out);
2144 }
2145 };
2146 crate::ingest::ingest_json_async(&mut conn, &array_text, &opts)
2147 .await
2148 .map(|mut r| {
2149 r.stats.operation = "load_file".into();
2150 r.stats.bytes_read =
2151 std::fs::metadata(&entry.path).map_or(0, |m| m.len());
2152 r.stats.file_format = Some("json".into());
2153 r
2154 })
2155 } else {
2156 match detect_file_format(std::path::Path::new(&entry.path)) {
2157 InferredFileFormat::Parquet => {
2158 ingest_parquet_file_async(&mut conn, &entry.path, &opts).await
2159 }
2160 InferredFileFormat::ArrowIpc => {
2161 ingest_arrow_ipc_file_async(&mut conn, &entry.path, &opts).await
2162 }
2163 InferredFileFormat::Json => {
2164 ingest_json_file_async(&mut conn, &entry.path, &opts).await
2165 }
2166 InferredFileFormat::Csv => {
2167 ingest_csv_file_async(&mut conn, &entry.path, &opts).await
2168 }
2169 }
2170 };
2171
2172 match ingest_res {
2173 Ok(r) => {
2174 let schema_json: Vec<Value> = r
2175 .schema
2176 .iter()
2177 .map(|c| {
2178 json!({
2179 "name": c.name,
2180 "type": c.hyper_type,
2181 "nullable": c.nullable,
2182 })
2183 })
2184 .collect();
2185 out.ok = Some((r.rows, schema_json, r.stats.to_json()));
2186 }
2187 Err(e) => {
2188 out.err = Some((e.code, e.message));
2189 }
2190 }
2191
2192 (idx, out)
2193 });
2194 }
2195
2196 let mut collected: Vec<Option<EntryOutcome>> =
2199 (0..file_count).map(|_| None).collect();
2200 while let Some(joined) = set.join_next().await {
2201 match joined {
2202 Ok((idx, outcome)) => collected[idx] = Some(outcome),
2203 Err(e) => {
2204 tracing::warn!("load_files task join error: {e}");
2207 }
2208 }
2209 }
2210 collected.into_iter().flatten().collect()
2211 })
2212 });
2213
2214 let mut any_replace_succeeded = false;
2218 let mut tables_to_notify: Vec<String> = Vec::new();
2219 let results_json: Vec<Value> = outcomes
2220 .iter()
2221 .map(|o| match (&o.ok, &o.err) {
2222 (Some((rows, schema, stats)), _) => {
2223 tables_to_notify.push(o.table.clone());
2224 if o.replace_mode {
2225 any_replace_succeeded = true;
2226 }
2227 json!({
2228 "table": o.table,
2229 "rows": rows,
2230 "schema": schema,
2231 "stats": stats,
2232 })
2233 }
2234 (None, Some((code, msg))) => json!({
2235 "table": o.table,
2236 "error": {
2237 "code": format!("{:?}", code),
2238 "message": msg,
2239 }
2240 }),
2241 (None, None) => json!({
2244 "table": o.table,
2245 "error": {
2246 "code": "InternalError",
2247 "message": "load_files task produced no outcome",
2248 }
2249 }),
2250 })
2251 .collect();
2252
2253 if let Err(e) = self.with_engine(|engine| {
2257 for o in &outcomes {
2258 if let Some((rows, _, _)) = &o.ok {
2259 self.after_ingest_catalog_update(
2260 engine,
2261 &o.table,
2262 "load_file",
2263 None,
2264 i64::try_from(*rows).ok(),
2265 target_db.as_deref(),
2266 );
2267 }
2268 }
2269 Ok(())
2270 }) {
2271 tracing::warn!("load_files: catalog update batch failed: {}", e.message);
2272 }
2273
2274 for t in &tables_to_notify {
2275 self.notify_table_changed(t);
2276 }
2277 if any_replace_succeeded {
2278 self.notify_resource_list_changed();
2279 }
2280
2281 let success_count = outcomes.iter().filter(|o| o.ok.is_some()).count();
2282 let failure_count = outcomes.len() - success_count;
2283
2284 Self::ok_content(json!({
2285 "results": results_json,
2286 "summary": {
2287 "total": outcomes.len(),
2288 "succeeded": success_count,
2289 "failed": failure_count,
2290 "concurrency": concurrency,
2291 }
2292 }))
2293 }
2294
2295 #[tool(
2298 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."
2299 )]
2300 fn load_iceberg(
2301 &self,
2302 Parameters(params): Parameters<LoadIcebergParams>,
2303 ) -> Result<CallToolResult, rmcp::ErrorData> {
2304 if let Err(e) = self.check_writable("load_iceberg") {
2305 return Self::err_content(e);
2306 }
2307 let table_name = params.table.clone();
2308 let mode = params.mode.clone().unwrap_or_else(|| "replace".into());
2309 let opts = crate::lakehouse::IcebergIngestOptions {
2310 table: params.table.clone(),
2311 mode: mode.clone(),
2312 metadata_filename: params.metadata_filename.clone(),
2313 version_as_of: params.version_as_of,
2314 };
2315
2316 let result = self.with_engine(|engine| {
2317 crate::attach::validate_input_path(¶ms.path, "iceberg table")?;
2319 let ingest_result =
2320 crate::lakehouse::ingest_iceberg_table(engine, ¶ms.path, &opts)?;
2321
2322 let schema_json: Vec<Value> = ingest_result
2323 .schema
2324 .iter()
2325 .map(|c| {
2326 json!({
2327 "name": c.name,
2328 "type": c.hyper_type,
2329 "nullable": c.nullable,
2330 })
2331 })
2332 .collect();
2333
2334 let load_params = serde_json::to_string(&json!({
2335 "source_path": params.path,
2336 "mode": mode,
2337 "format": "iceberg",
2338 "metadata_filename": params.metadata_filename,
2339 "version_as_of": params.version_as_of,
2340 }))
2341 .ok();
2342 self.after_ingest_catalog_update(
2343 engine,
2344 ¶ms.table,
2345 "load_iceberg",
2346 load_params.as_deref(),
2347 i64::try_from(ingest_result.rows).ok(),
2348 None,
2349 );
2350
2351 Ok(json!({
2352 "rows": ingest_result.rows,
2353 "schema": schema_json,
2354 "stats": ingest_result.stats.to_json(),
2355 }))
2356 });
2357
2358 match result {
2359 Ok(val) => {
2360 self.notify_table_changed(&table_name);
2361 if mode == "replace" {
2362 self.notify_resource_list_changed();
2363 }
2364 Self::ok_content(val)
2365 }
2366 Err(e) => Self::err_content(e),
2367 }
2368 }
2369
2370 #[tool(
2372 description = "Run a read-only SQL query (SELECT, WITH, EXPLAIN, SHOW, VALUES) against the workspace. For DDL/DML use the execute tool."
2373 )]
2374 fn query(
2375 &self,
2376 Parameters(params): Parameters<QueryParams>,
2377 ) -> Result<CallToolResult, rmcp::ErrorData> {
2378 let result = self.with_engine(|engine| {
2379 if !is_read_only_sql(¶ms.sql) {
2380 return Err(McpError::new(
2381 ErrorCode::SqlError,
2382 "The query tool only accepts read-only SQL (SELECT, WITH, EXPLAIN, SHOW, VALUES). Use the execute tool for DDL/DML.",
2383 ));
2384 }
2385 let target_db = self.resolve_db(engine, params.database.as_deref(), None, false)?;
2388 let _search_guard = match target_db {
2389 Some(ref alias) => Some(engine.scoped_search_path(alias)?),
2390 None => None,
2391 };
2392 const MAX_QUERY_ROWS: usize = 10_000;
2396
2397 let timer = crate::stats::StatsTimer::start();
2398 let mut rows = engine.execute_query_to_json(¶ms.sql)?;
2399 let total_rows = rows.len();
2400 let truncated = total_rows > MAX_QUERY_ROWS;
2401 if truncated {
2402 rows.truncate(MAX_QUERY_ROWS);
2403 }
2404 let elapsed = timer.elapsed_ms();
2405 let stats = crate::stats::QueryStats {
2406 operation: "query".into(),
2407 rows_returned: rows.len() as u64,
2408 rows_scanned: 0,
2409 elapsed_ms: elapsed,
2410 result_size_bytes: serde_json::to_string(&rows).map_or(0, |s| s.len() as u64),
2411 tables_touched: vec![],
2412 };
2413 let payload = if truncated {
2414 json!({
2415 "result": rows,
2416 "stats": stats.to_json(),
2417 "truncated": true,
2418 "total_rows": total_rows,
2419 "rows_returned": MAX_QUERY_ROWS,
2420 "hint": format!(
2421 "Result set has {total_rows} rows; only the first {MAX_QUERY_ROWS} \
2422 are shown. Add a LIMIT clause, aggregate with GROUP BY, or use \
2423 the `export` tool to write the full result to a file."
2424 ),
2425 })
2426 } else {
2427 json!({
2428 "result": rows,
2429 "stats": stats.to_json(),
2430 })
2431 };
2432 Ok((params.sql.clone(), payload))
2433 });
2434
2435 match result {
2436 Ok((sql, val)) => {
2437 let formatted_sql = Self::fmt_sql(&sql);
2438 let json_text = serde_json::to_string_pretty(&val).unwrap_or_default();
2439 Ok(CallToolResult::success(vec![
2440 Content::text(format!("```sql\n{formatted_sql}\n```")),
2441 Content::text(json_text),
2442 ]))
2443 }
2444 Err(e) => Self::err_content(e),
2445 }
2446 }
2447
2448 #[tool(
2450 description = "Execute one or more DDL/DML statements as an atomic batch. `sql` is an array of statements; pass `[\"SQL\"]` for a single statement or `[\"UPDATE …\", \"INSERT …\"]` for an atomic upsert. Multi-statement batches run inside a transaction — if any statement fails, all are rolled back. Mixing DDL with DML in one batch is rejected (Hyper aborts such transactions). Disabled in read-only mode."
2451 )]
2452 fn execute(
2453 &self,
2454 Parameters(params): Parameters<ExecuteParams>,
2455 ) -> Result<CallToolResult, rmcp::ErrorData> {
2456 if let Err(e) = self.check_writable("execute") {
2457 return Self::err_content(e);
2458 }
2459 if let Err(e) = validate_execute_batch(¶ms.sql) {
2464 return Self::err_content(e);
2465 }
2466 let any_structural = params
2467 .sql
2468 .iter()
2469 .any(|s| matches!(classify_statement(s), StatementKind::Ddl));
2470 let result = self.with_engine(|engine| {
2471 let target_db = self.resolve_db(engine, params.database.as_deref(), None, true)?;
2475 let _search_guard = match target_db {
2476 Some(ref alias) => Some(engine.scoped_search_path(alias)?),
2477 None => None,
2478 };
2479 let total_timer = crate::stats::StatsTimer::start();
2480 let (per_statement, affected_total, operation): (Vec<Value>, u64, &'static str) =
2481 if params.sql.len() == 1 {
2482 let stmt = ¶ms.sql[0];
2486 let t = crate::stats::StatsTimer::start();
2487 let affected = engine.execute_command(stmt)?;
2488 (
2489 vec![json!({
2490 "sql": Self::fmt_sql(stmt),
2491 "affected_rows": affected,
2492 "elapsed_ms": t.elapsed_ms(),
2493 })],
2494 affected,
2495 "command",
2496 )
2497 } else {
2498 let stmts = ¶ms.sql;
2499 let (results, total) = engine.execute_in_transaction(|engine| {
2500 let mut out = Vec::with_capacity(stmts.len());
2501 let mut total: u64 = 0;
2502 for (idx, stmt) in stmts.iter().enumerate() {
2503 let t = crate::stats::StatsTimer::start();
2504 let affected = engine.execute_command(stmt).map_err(|e| {
2505 let rollback_note = format!(
2510 "Failing SQL: {sql}. All previous statements in this batch were rolled back.",
2511 sql = Self::fmt_sql(stmt)
2512 );
2513 let combined = match e.suggestion.as_deref() {
2514 Some(orig) => format!("{orig} | {rollback_note}"),
2515 None => rollback_note,
2516 };
2517 McpError::new(
2518 e.code,
2519 format!(
2520 "statement {} of {} failed: {}",
2521 idx + 1,
2522 stmts.len(),
2523 e.message
2524 ),
2525 )
2526 .with_suggestion(combined)
2527 })?;
2528 total = total.saturating_add(affected);
2532 out.push(json!({
2533 "sql": Self::fmt_sql(stmt),
2534 "affected_rows": affected,
2535 "elapsed_ms": t.elapsed_ms(),
2536 }));
2537 }
2538 Ok((out, total))
2539 })?;
2540 (results, total, "transaction")
2541 };
2542 let elapsed = total_timer.elapsed_ms();
2543 if any_structural {
2556 self.after_execute_catalog_update(engine, target_db.as_deref());
2557 }
2558 Ok(json!({
2559 "statements": per_statement.len(),
2560 "affected_rows": affected_total,
2561 "per_statement": per_statement,
2562 "stats": { "operation": operation, "elapsed_ms": elapsed },
2563 }))
2564 });
2565
2566 match result {
2567 Ok(val) => {
2568 self.notify_workspace_changed();
2573 if any_structural {
2574 self.notify_resource_list_changed();
2575 }
2576 Self::ok_content(val)
2577 }
2578 Err(e) => Self::err_content(e),
2579 }
2580 }
2581
2582 #[tool(
2584 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."
2585 )]
2586 fn sample(
2587 &self,
2588 Parameters(params): Parameters<SampleParams>,
2589 ) -> Result<CallToolResult, rmcp::ErrorData> {
2590 let result = self.with_engine(|engine| {
2591 let target_db = self.resolve_db(engine, params.database.as_deref(), None, false)?;
2592 let timer = crate::stats::StatsTimer::start();
2593 let n = params.n.unwrap_or(5);
2594 let mut sample = engine.sample_table_in(target_db.as_deref(), ¶ms.table, n)?;
2595 let elapsed = timer.elapsed_ms();
2596 if let Some(obj) = sample.as_object_mut() {
2597 obj.insert(
2598 "stats".into(),
2599 json!({ "operation": "sample", "elapsed_ms": elapsed }),
2600 );
2601 }
2602 Ok(sample)
2603 });
2604
2605 match result {
2606 Ok(val) => Self::ok_content(val),
2607 Err(e) => Self::err_content(e),
2608 }
2609 }
2610
2611 #[tool(
2613 description = "Render a chart (bar, line, scatter, or histogram) from a SQL query. Returns the PNG/SVG image inline by default so MCP clients can display it directly. Set `inline=false` to skip the inline bytes and write to disk only (keeps the MCP transcript small for batch workflows). Combine `inline=true` with `output_path` to get both.\n\n**Data shape:** The query must return long-format data with one numeric `y` column. For multi-series charts, use a `series` column to split by category. If your data is wide-format (multiple value columns), reshape it with `UNION ALL` into (label, series, value) tuples before charting.\n\n**DATE/TIMESTAMP x-axis:** Line and scatter charts auto-detect non-numeric x columns. DATE, TIMESTAMP, and TIMESTAMPTZ values render with a **proportional time axis** — gaps between data points reflect real wall-clock time (4.5 h gap and 17 h gap don't look the same). Tick labels are formatted in the input kind: `%Y-%m-%d` for DATE, `%Y-%m-%d %H:%M:%S` for TIMESTAMP, with the originating timezone offset preserved for TIMESTAMPTZ. TEXT x columns fall back to evenly-spaced categorical mode. Set `x_as_category: true` to force categorical layout on temporal data (useful when even spacing reads better than proportional gaps).\n\n- `output_path`: explicit destination file path. Parent directory is created automatically (no need to pre-create it). If omitted and `inline=true` (default), no file is written. If omitted and `inline=false`, a file is auto-generated under the system temp dir as `hyperdb-charts/chart-<ts>-<pid>-<n>.<ext>`.\n- `inline`: when true (default), return the image bytes inline. Without `output_path`, suppresses the disk write entirely. With `output_path`, writes to disk AND returns inline. Set to false for disk-only output.\n- `format`: \"png\" (default) or \"svg\". Auto-derived from `output_path` extension when omitted. A mismatch between `format` and the path extension returns `INVALID_ARGUMENT`.\n- `overwrite`: default true. Set false to refuse overwriting an existing file (returns `PERMISSION_DENIED`).\n- `x_range` / `y_range`: fix axis extents across multiple charts (e.g. x_range=[0,1500], y_range=[0,1]).\n- `color_map`: stable per-series hex colors (e.g. {\"India\":\"#e41a1c\",\"China\":\"#ff7f0e\"}).\n- `label_points=true`: annotate each point with its series name instead of showing a legend — best when each series has exactly one point."
2614 )]
2615 fn chart(
2616 &self,
2617 Parameters(params): Parameters<ChartParams>,
2618 ) -> Result<CallToolResult, rmcp::ErrorData> {
2619 let result = self.with_engine(|engine| {
2620 if !is_read_only_sql(¶ms.sql) {
2621 return Err(McpError::new(
2622 ErrorCode::SqlError,
2623 "The chart tool only accepts read-only SQL (SELECT, WITH, EXPLAIN, SHOW, VALUES).",
2624 ));
2625 }
2626
2627 if let Some(out) = params.output_path.as_deref() {
2630 crate::attach::validate_output_path(out, "chart output")?;
2631 }
2632 let format = crate::chart::resolve_chart_format(
2635 params.format.as_deref(),
2636 params.output_path.as_deref(),
2637 )?;
2638
2639 let target_db = self.resolve_db(engine, params.database.as_deref(), None, false)?;
2642 let _search_guard = match target_db {
2643 Some(ref alias) => Some(engine.scoped_search_path(alias)?),
2644 None => None,
2645 };
2646
2647 let timer = crate::stats::StatsTimer::start();
2648 let rows = engine.execute_query_to_json(¶ms.sql)?;
2649
2650 let color_map = params
2653 .color_map
2654 .as_ref()
2655 .map(|m| {
2656 m.iter()
2657 .filter_map(|(k, v)| {
2658 crate::chart::parse_hex_color(v)
2659 .map(|c| (k.clone(), c))
2660 })
2661 .collect::<std::collections::HashMap<_, _>>()
2662 })
2663 .unwrap_or_default();
2664
2665 let opts = ChartOptions {
2666 chart_type: ChartType::parse(¶ms.chart_type)?,
2667 x_column: params.x.clone(),
2668 y_column: params.y.clone(),
2669 series_column: params.series.clone(),
2670 title: params.title.clone(),
2671 format,
2672 width: params.width.unwrap_or(800).clamp(200, 4096),
2673 height: params.height.unwrap_or(480).clamp(150, 4096),
2674 bins: params.bins.unwrap_or(20).clamp(1, 500),
2675 x_as_category: params.x_as_category,
2676 x_range: params.x_range,
2677 y_range: params.y_range,
2678 color_map,
2679 label_points: params.label_points.unwrap_or(false),
2680 };
2681
2682 let chart = render_chart(&rows, &opts)?;
2683
2684 let disposition = crate::chart::resolve_chart_disposition(
2688 params.inline.unwrap_or(true),
2689 params.output_path.as_deref(),
2690 opts.format,
2691 );
2692 let overwrite = params.overwrite.unwrap_or(true);
2693 if let Some(path) = disposition.path() {
2694 crate::chart::write_chart_to_disk(path, &chart.bytes, overwrite)?;
2695 }
2696
2697 let elapsed = timer.elapsed_ms();
2698 Ok((chart, elapsed, opts, disposition))
2699 });
2700
2701 match result {
2702 Ok((chart, elapsed_ms, opts, disposition)) => {
2703 let format_str = match opts.format {
2704 ChartFormat::Png => "png",
2705 ChartFormat::Svg => "svg",
2706 };
2707 let wants_inline = disposition.wants_inline();
2708 let output_path_str = disposition.path().map(|p| p.to_string_lossy().into_owned());
2709
2710 let mut stats = serde_json::Map::new();
2711 stats.insert("operation".into(), json!("chart"));
2712 stats.insert("rows_plotted".into(), json!(chart.rows_plotted));
2713 stats.insert("elapsed_ms".into(), json!(elapsed_ms));
2714 stats.insert("format".into(), json!(format_str));
2715 stats.insert("bytes".into(), json!(chart.bytes.len()));
2716 stats.insert("width".into(), json!(opts.width));
2717 stats.insert("height".into(), json!(opts.height));
2718 stats.insert("inline".into(), json!(wants_inline));
2719 if let Some(p) = output_path_str {
2720 stats.insert("output_path".into(), json!(p));
2721 }
2722 let stats_text =
2723 serde_json::to_string_pretty(&Value::Object(stats)).unwrap_or_default();
2724
2725 let mut content = Vec::with_capacity(2);
2726 if wants_inline {
2727 let b64 = base64::engine::general_purpose::STANDARD.encode(&chart.bytes);
2728 content.push(Content::image(b64, chart.mime_type.to_string()));
2729 }
2730 content.push(Content::text(stats_text));
2731 Ok(CallToolResult::success(content))
2732 }
2733 Err(e) => Self::err_content(e),
2734 }
2735 }
2736
2737 #[tool(
2740 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."
2741 )]
2742 fn watch_directory(
2743 &self,
2744 Parameters(params): Parameters<WatchDirectoryParams>,
2745 ) -> Result<CallToolResult, rmcp::ErrorData> {
2746 if let Err(e) = self.check_writable("watch_directory") {
2747 return Self::err_content(e);
2748 }
2749 let canonical = match crate::attach::validate_input_path(¶ms.path, "watch directory") {
2750 Ok(p) => p,
2751 Err(e) => return Self::err_content(e),
2752 };
2753 match self.ensure_engine() {
2756 Ok(guard) => drop(guard),
2757 Err(e) => return Self::err_content(e),
2758 }
2759
2760 let target_db = match self.with_engine(|engine| {
2764 self.resolve_db(engine, params.database.as_deref(), params.persist, true)
2765 }) {
2766 Ok(v) => v,
2767 Err(e) => return Self::err_content(e),
2768 };
2769
2770 let path = canonical;
2771 let engine_handle = self.engine_handle();
2772 let attachments = self.attachments_handle();
2773 let registry = self.watchers_handle();
2774 let options = crate::watcher::WatchOptions {
2775 max_concurrent: params.max_concurrent.unwrap_or(0) as usize,
2776 };
2777 let result = crate::watcher::start_watching(
2778 engine_handle,
2779 attachments,
2780 registry,
2781 Some(self.subscriptions_handle()),
2782 path.clone(),
2783 params.table.clone(),
2784 target_db,
2785 options,
2786 );
2787 match result {
2788 Ok(stats) => {
2789 let body = json!({
2790 "directory": path.to_string_lossy(),
2791 "table": params.table,
2792 "status": "watching",
2793 "max_concurrent": stats.max_concurrent,
2794 "initial_sweep": {
2795 "files_ingested": stats.files_ingested,
2796 "files_failed": stats.files_failed,
2797 },
2798 });
2799 Self::ok_content(body)
2800 }
2801 Err(e) => Self::err_content(e),
2802 }
2803 }
2804
2805 #[tool(
2807 description = "Stop watching a directory previously registered with watch_directory. Pending .ready files are left in place."
2808 )]
2809 fn unwatch_directory(
2810 &self,
2811 Parameters(params): Parameters<UnwatchDirectoryParams>,
2812 ) -> Result<CallToolResult, rmcp::ErrorData> {
2813 let path = std::path::PathBuf::from(¶ms.path);
2814 let result = crate::watcher::stop_watching(&self.watchers_handle(), &path);
2815 match result {
2816 Ok(summary) => Self::ok_content(summary),
2817 Err(e) => Self::err_content(e),
2818 }
2819 }
2820
2821 #[tool(
2824 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."
2825 )]
2826 fn describe(
2827 &self,
2828 Parameters(params): Parameters<DescribeParams>,
2829 ) -> Result<CallToolResult, rmcp::ErrorData> {
2830 let result = self.with_engine(|engine| {
2831 let target_db = self.resolve_db(engine, params.database.as_deref(), None, false)?;
2832 match params.table.as_deref() {
2833 Some(name) => engine
2834 .describe_table_in(target_db.as_deref(), name)
2835 .map(|t| vec![t]),
2836 None => engine.describe_tables_in(target_db.as_deref()),
2837 }
2838 });
2839
2840 match result {
2841 Ok(tables) => Self::ok_content(json!({"tables": tables})),
2842 Err(e) => Self::err_content(e),
2843 }
2844 }
2845
2846 #[tool(
2851 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)."
2852 )]
2853 #[expect(
2854 clippy::unused_self,
2855 reason = "method retained on the type for API symmetry; implementation currently does not need state"
2856 )]
2857 fn inspect_file(
2858 &self,
2859 Parameters(params): Parameters<InspectFileParams>,
2860 ) -> Result<CallToolResult, rmcp::ErrorData> {
2861 if let Err(e) = crate::attach::validate_input_path(¶ms.path, "data file") {
2862 return Self::err_content(e);
2863 }
2864 let sample_rows = params.sample_rows.unwrap_or(5).clamp(1, 50) as usize;
2865 let result = if let Some(ref json_path) = params.json_extract_path {
2866 (|| -> Result<_, McpError> {
2867 let raw = std::fs::read_to_string(¶ms.path).map_err(|e| {
2868 McpError::new(
2869 ErrorCode::FileNotFound,
2870 format!("Cannot read file '{}': {e}", params.path),
2871 )
2872 })?;
2873 let file_size = std::fs::metadata(¶ms.path).map_or(0, |m| m.len());
2874 let extracted = crate::ingest::extract_json_path(&raw, json_path)?;
2875 crate::inspect::inspect_json_from_text(&extracted, file_size, sample_rows)
2876 })()
2877 } else {
2878 crate::inspect::inspect_source(¶ms.path, sample_rows)
2879 };
2880 match result {
2881 Ok(report) => Self::ok_content(report.to_json()),
2882 Err(e) => Self::err_content(e),
2883 }
2884 }
2885
2886 #[tool(
2889 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)."
2890 )]
2891 fn export(
2892 &self,
2893 Parameters(params): Parameters<ExportParams>,
2894 ) -> Result<CallToolResult, rmcp::ErrorData> {
2895 let result = self.with_engine(|engine| {
2896 crate::attach::validate_output_path(¶ms.path, "export")?;
2899 let format_options = match params.format_options.clone() {
2903 None => None,
2904 Some(Value::Object(m)) => Some(m),
2905 Some(other) => {
2906 return Err(McpError::new(
2907 ErrorCode::SchemaMismatch,
2908 format!("export: format_options must be a JSON object, got: {other}"),
2909 ));
2910 }
2911 };
2912 let target_db = self.resolve_db(engine, params.database.as_deref(), None, false)?;
2923 let (effective_sql, effective_table) = match (¶ms.sql, ¶ms.table, &target_db) {
2924 (None, Some(t), Some(db)) => {
2925 let esc_db = db.replace('"', "\"\"");
2926 let esc_tbl = t.replace('"', "\"\"");
2927 (
2928 Some(format!(
2929 "SELECT * FROM \"{esc_db}\".\"public\".\"{esc_tbl}\""
2930 )),
2931 None,
2932 )
2933 }
2934 _ => (params.sql.clone(), params.table.clone()),
2935 };
2936 let _search_guard = match (&effective_sql, &target_db, ¶ms.sql) {
2937 (Some(_), Some(alias), Some(_)) => Some(engine.scoped_search_path(alias)?),
2940 _ => None,
2941 };
2942 let opts = ExportOptions {
2943 sql: effective_sql,
2944 table: effective_table,
2945 path: params.path,
2946 format: params.format,
2947 overwrite: params.overwrite.unwrap_or(true),
2948 format_options,
2949 source_db: target_db.clone(),
2950 };
2951 let export_result = export_to_file(engine, &opts)?;
2952 Ok(json!({
2953 "output_path": export_result.stats.output_path,
2954 "rows": export_result.rows,
2955 "file_size_bytes": export_result.stats.file_size_bytes,
2956 "stats": export_result.stats.to_json(),
2957 }))
2958 });
2959
2960 match result {
2961 Ok(val) => Self::ok_content(val),
2962 Err(e) => Self::err_content(e),
2963 }
2964 }
2965
2966 #[tool(
2970 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."
2971 )]
2972 fn save_query(
2973 &self,
2974 Parameters(params): Parameters<SaveQueryParams>,
2975 ) -> Result<CallToolResult, rmcp::ErrorData> {
2976 if let Err(e) = self.check_writable("save_query") {
2977 return Self::err_content(e);
2978 }
2979 if !is_read_only_sql(¶ms.sql) {
2984 return Self::err_content(McpError::new(
2985 ErrorCode::SqlError,
2986 "save_query only accepts read-only SQL (SELECT / WITH / EXPLAIN / SHOW / VALUES). \
2987 Use the execute tool for DDL/DML, not save_query.",
2988 ));
2989 }
2990 if params.name.is_empty() {
2991 return Self::err_content(McpError::new(
2992 ErrorCode::SchemaMismatch,
2993 "Saved query name must not be empty.",
2994 ));
2995 }
2996 let query = SavedQuery {
2997 name: params.name.clone(),
2998 sql: params.sql,
2999 description: params.description,
3000 created_at: chrono::Utc::now(),
3001 };
3002 let store = Arc::clone(&self.saved_queries);
3003 let result = self.with_saved_query_store(|engine| store.save(engine, query.clone()));
3004 match result {
3005 Ok(()) => {
3006 self.notify_resource_list_changed();
3010 Self::ok_content(json!({
3011 "saved": true,
3012 "name": query.name,
3013 "resources": [
3014 format!("hyper://queries/{}/definition", query.name),
3015 format!("hyper://queries/{}/result", query.name),
3016 ],
3017 "created_at": query.created_at.to_rfc3339(),
3018 }))
3019 }
3020 Err(e) => Self::err_content(e),
3021 }
3022 }
3023
3024 #[tool(
3026 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)."
3027 )]
3028 fn delete_query(
3029 &self,
3030 Parameters(params): Parameters<DeleteQueryParams>,
3031 ) -> Result<CallToolResult, rmcp::ErrorData> {
3032 if let Err(e) = self.check_writable("delete_query") {
3033 return Self::err_content(e);
3034 }
3035 let store = Arc::clone(&self.saved_queries);
3036 let name = params.name.clone();
3037 let result = self.with_saved_query_store(|engine| store.delete(engine, &name));
3038 match result {
3039 Ok(deleted) => {
3040 if deleted {
3041 self.notify_resource_list_changed();
3046 self.subscriptions
3047 .notify_updated(&format!("hyper://queries/{name}/definition"));
3048 self.subscriptions
3049 .notify_updated(&format!("hyper://queries/{name}/result"));
3050 }
3051 Self::ok_content(json!({
3052 "deleted": deleted,
3053 "name": params.name,
3054 }))
3055 }
3056 Err(e) => Self::err_content(e),
3057 }
3058 }
3059
3060 #[tool(
3062 description = "Update prose metadata for a table in the `_table_catalog`: source_url, source_description, purpose, license, notes, data_url. Fields you omit stay unchanged; pass an explicit empty string (\"\") to clear a field. `data_url` is the machine-actionable download URL for the raw data file (distinct from `source_url`, which is a human-readable reference page). Mechanical fields (load_tool, load_params, loaded_at, last_refreshed_at, row_count) are managed by the server. Requires an existing catalog entry — load the table first (load_file / load_data / execute CREATE TABLE) so the stub row is created automatically. Use `database` to target the metadata for a table in a non-primary writable database; read-only attachments are rejected with a clear re-attach-with-writable message. Disabled in read-only mode."
3063 )]
3064 fn set_table_metadata(
3065 &self,
3066 Parameters(params): Parameters<SetTableMetadataParams>,
3067 ) -> Result<CallToolResult, rmcp::ErrorData> {
3068 if let Err(e) = self.check_writable("set_table_metadata") {
3069 return Self::err_content(e);
3070 }
3071 let fields = crate::table_catalog::MetadataFields {
3072 source_url: params.source_url,
3073 source_description: params.source_description,
3074 purpose: params.purpose,
3075 license: params.license,
3076 notes: params.notes,
3077 data_url: params.data_url,
3078 };
3079 let table_name = params.table.clone();
3080 let result = self.with_engine(|engine| {
3081 let target_db = self.resolve_db(engine, params.database.as_deref(), None, true)?;
3087 crate::table_catalog::set_metadata_in(
3088 engine,
3089 &table_name,
3090 &fields,
3091 target_db.as_deref(),
3092 )
3093 });
3094 match result {
3095 Ok(entry) => Self::ok_content(entry.to_json()),
3096 Err(e) => Self::err_content(e),
3097 }
3098 }
3099
3100 #[tool(
3102 description = "Read a value from the KV scratchpad by store + key. Returns {found, value}; `value` is null when the key is absent (not an error). Omit `database` to read the ephemeral store; pass \"persistent\" (or persist=true) or an attached alias to read elsewhere."
3103 )]
3104 fn kv_get(
3105 &self,
3106 Parameters(p): Parameters<KvKeyParams>,
3107 ) -> Result<CallToolResult, rmcp::ErrorData> {
3108 let result = self.with_engine(|engine| {
3109 let db = self.resolve_db(engine, p.database.as_deref(), p.persist, true)?;
3110 let kv = Self::kv_open(engine, db.as_deref(), &p.store)?;
3111 kv.get(&p.key).map_err(McpError::from)
3112 });
3113 match result {
3114 Ok(value) => Self::ok_content(json!({ "found": value.is_some(), "value": value })),
3115 Err(e) => Self::err_content(e),
3116 }
3117 }
3118
3119 #[tool(
3121 description = "KV scratchpad. Save a variable, state, summary, or JSON config under store + key to remember later without creating a database table. Overwrites any existing value (upsert). IMPORTANT: without `database` the value is written to the EPHEMERAL database and is LOST when the server restarts. To persist across restarts, pass database=\"persistent\" (or persist=true)."
3122 )]
3123 fn kv_set(
3124 &self,
3125 Parameters(p): Parameters<KvSetParams>,
3126 ) -> Result<CallToolResult, rmcp::ErrorData> {
3127 if let Err(e) = self.check_writable("kv_set") {
3128 return Self::err_content(e);
3129 }
3130 let result = self.with_engine(|engine| {
3131 let db = self.resolve_db(engine, p.database.as_deref(), p.persist, true)?;
3132 let kv = Self::kv_open(engine, db.as_deref(), &p.store)?;
3133 kv.set(&p.key, &p.value).map_err(McpError::from)
3134 });
3135 match result {
3136 Ok(()) => Self::ok_content(json!({ "stored": true, "store": p.store, "key": p.key })),
3137 Err(e) => Self::err_content(e),
3138 }
3139 }
3140
3141 #[tool(
3143 description = "Delete a key from the KV scratchpad. Returns {deleted: true} when the key existed, {deleted: false} otherwise (no error). Omit `database` for the ephemeral store, or route with \"persistent\"/persist=true/an attached alias."
3144 )]
3145 fn kv_delete(
3146 &self,
3147 Parameters(p): Parameters<KvKeyParams>,
3148 ) -> Result<CallToolResult, rmcp::ErrorData> {
3149 if let Err(e) = self.check_writable("kv_delete") {
3150 return Self::err_content(e);
3151 }
3152 let result = self.with_engine(|engine| {
3153 let db = self.resolve_db(engine, p.database.as_deref(), p.persist, true)?;
3154 let kv = Self::kv_open(engine, db.as_deref(), &p.store)?;
3155 kv.delete(&p.key).map_err(McpError::from)
3156 });
3157 match result {
3158 Ok(deleted) => {
3159 Self::ok_content(json!({ "deleted": deleted, "store": p.store, "key": p.key }))
3160 }
3161 Err(e) => Self::err_content(e),
3162 }
3163 }
3164
3165 #[tool(
3167 description = "List all keys in a KV scratchpad store, sorted ascending. Omit `database` for the ephemeral store, or route with \"persistent\"/persist=true/an attached alias."
3168 )]
3169 fn kv_list(
3170 &self,
3171 Parameters(p): Parameters<KvStoreParams>,
3172 ) -> Result<CallToolResult, rmcp::ErrorData> {
3173 let result = self.with_engine(|engine| {
3174 let db = self.resolve_db(engine, p.database.as_deref(), p.persist, true)?;
3175 let kv = Self::kv_open(engine, db.as_deref(), &p.store)?;
3176 kv.keys().map_err(McpError::from)
3177 });
3178 match result {
3179 Ok(keys) => {
3180 Self::ok_content(json!({ "store": p.store, "count": keys.len(), "keys": keys }))
3181 }
3182 Err(e) => Self::err_content(e),
3183 }
3184 }
3185
3186 #[tool(
3188 description = "List all KV scratchpad store namespaces that currently hold data in a database. Omit `database` for the ephemeral store, or route with \"persistent\"/persist=true/an attached alias. Each database has its own isolated set of stores. A store drops off this list once its last key is removed — there is no separate registry."
3189 )]
3190 fn kv_list_stores(
3191 &self,
3192 Parameters(p): Parameters<KvListStoresParams>,
3193 ) -> Result<CallToolResult, rmcp::ErrorData> {
3194 let result = self.with_engine(|engine| {
3195 let db = self.resolve_db(engine, p.database.as_deref(), p.persist, true)?;
3196 match db.as_deref() {
3197 Some(alias) => engine.connection().kv_list_stores_in(alias),
3198 None => engine.connection().kv_list_stores(),
3199 }
3200 .map_err(McpError::from)
3201 });
3202 match result {
3203 Ok(stores) => Self::ok_content(json!({ "count": stores.len(), "stores": stores })),
3204 Err(e) => Self::err_content(e),
3205 }
3206 }
3207
3208 #[tool(
3210 description = "Return the number of keys in a KV scratchpad store. Omit `database` for the ephemeral store, or route with \"persistent\"/persist=true/an attached alias."
3211 )]
3212 fn kv_size(
3213 &self,
3214 Parameters(p): Parameters<KvStoreParams>,
3215 ) -> Result<CallToolResult, rmcp::ErrorData> {
3216 let result = self.with_engine(|engine| {
3217 let db = self.resolve_db(engine, p.database.as_deref(), p.persist, true)?;
3218 let kv = Self::kv_open(engine, db.as_deref(), &p.store)?;
3219 kv.size().map_err(McpError::from)
3220 });
3221 match result {
3222 Ok(size) => Self::ok_content(json!({ "store": p.store, "size": size })),
3223 Err(e) => Self::err_content(e),
3224 }
3225 }
3226
3227 #[tool(
3230 description = "Destructively read-and-remove the lowest-keyed entry (lexicographic key order, not insertion order) from a KV store (peek+delete in one transaction, atomic within a single server process — useful as a work queue for one session; two separate server processes popping a shared persistent store could double-serve an entry). Returns {found, key, value}; {found: false} on an empty store. Omit `database` for the ephemeral store, or route with \"persistent\"/persist=true/an attached alias."
3231 )]
3232 fn kv_pop(
3233 &self,
3234 Parameters(p): Parameters<KvStoreParams>,
3235 ) -> Result<CallToolResult, rmcp::ErrorData> {
3236 if let Err(e) = self.check_writable("kv_pop") {
3237 return Self::err_content(e);
3238 }
3239 let result = self.with_engine(|engine| {
3240 let db = self.resolve_db(engine, p.database.as_deref(), p.persist, true)?;
3241 let kv = Self::kv_open(engine, db.as_deref(), &p.store)?;
3242 kv.pop().map_err(McpError::from)
3243 });
3244 match result {
3245 Ok(Some((key, value))) => {
3246 Self::ok_content(json!({ "found": true, "key": key, "value": value }))
3247 }
3248 Ok(None) => Self::ok_content(json!({ "found": false })),
3249 Err(e) => Self::err_content(e),
3250 }
3251 }
3252
3253 #[tool(
3255 description = "Delete all keys in a KV scratchpad store. Returns the number of keys removed. Omit `database` for the ephemeral store, or route with \"persistent\"/persist=true/an attached alias."
3256 )]
3257 fn kv_clear(
3258 &self,
3259 Parameters(p): Parameters<KvStoreParams>,
3260 ) -> Result<CallToolResult, rmcp::ErrorData> {
3261 if let Err(e) = self.check_writable("kv_clear") {
3262 return Self::err_content(e);
3263 }
3264 let result = self.with_engine(|engine| {
3265 let db = self.resolve_db(engine, p.database.as_deref(), p.persist, true)?;
3266 let kv = Self::kv_open(engine, db.as_deref(), &p.store)?;
3267 kv.clear().map_err(McpError::from)
3268 });
3269 match result {
3270 Ok(removed) => Self::ok_content(json!({ "store": p.store, "removed": removed })),
3271 Err(e) => Self::err_content(e),
3272 }
3273 }
3274
3275 #[tool(
3279 description = "Returns plugin health, workspace info, table count, total rows, disk usage, the backing hyperd connection (engine.mode, engine.hyperd_endpoint, engine.daemon_health_port), and active directory watchers."
3280 )]
3281 fn status(&self) -> Result<CallToolResult, rmcp::ErrorData> {
3282 let Ok(guard) = self.engine.try_lock() else {
3295 return Self::ok_content(self.status_degraded());
3296 };
3297 let Some(engine) = guard.as_ref() else {
3298 return Self::ok_content(self.status_degraded());
3299 };
3300 self.ensure_catalog_ready(engine);
3301 let result = engine.status();
3302
3303 match result {
3304 Ok(mut val) => {
3305 if let Some(obj) = val.as_object_mut() {
3306 obj.insert("engine_busy".into(), json!(false));
3307 obj.insert("watchers".into(), self.watchers.to_json());
3308 obj.insert("read_only".into(), json!(self.read_only));
3309 let attachments: Vec<Value> = self
3310 .attachments
3311 .list()
3312 .iter()
3313 .map(super::attach::AttachedDb::to_json)
3314 .collect();
3315 obj.insert("attachments".into(), Value::Array(attachments));
3316 }
3317 Self::ok_content(val)
3318 }
3319 Err(e) => Self::err_content(e),
3320 }
3321 }
3322
3323 #[tool(
3328 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."
3329 )]
3330 #[expect(
3331 clippy::unused_self,
3332 reason = "the #[tool] macro dispatches on &self; signature must match the rest of the tool surface even though this tool is stateless"
3333 )]
3334 #[expect(
3335 clippy::unnecessary_wraps,
3336 reason = "uniform Result<CallToolResult, rmcp::ErrorData> across all tools so the #[tool_router] dispatcher has one signature shape"
3337 )]
3338 fn get_readme(&self) -> Result<CallToolResult, rmcp::ErrorData> {
3339 Ok(CallToolResult::success(vec![Content::text(
3340 crate::readme::README,
3341 )]))
3342 }
3343
3344 #[tool(
3347 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."
3348 )]
3349 fn attach_database(
3350 &self,
3351 Parameters(params): Parameters<AttachDatabaseParams>,
3352 ) -> Result<CallToolResult, rmcp::ErrorData> {
3353 let writable = params.writable.unwrap_or(false);
3354 if writable {
3355 if let Err(e) = self.check_writable("attach_database(writable)") {
3356 return Self::err_content(e);
3357 }
3358 }
3359 let on_missing = match attach::OnMissing::parse(params.on_missing.as_deref()) {
3360 Ok(v) => v,
3361 Err(e) => return Self::err_content(e),
3362 };
3363 if on_missing == attach::OnMissing::Create && !writable {
3364 return Self::err_content(McpError::new(
3365 ErrorCode::InvalidArgument,
3366 "on_missing='create' requires writable:true — an empty .hyper file that cannot be written to cannot be populated.",
3367 ));
3368 }
3369 let source = match params.kind.as_str() {
3370 "local_file" => {
3371 let Some(raw) = params.path.as_deref() else {
3372 return Self::err_content(McpError::new(
3373 ErrorCode::InvalidArgument,
3374 "kind='local_file' requires a 'path' argument",
3375 ));
3376 };
3377 let resolved = match on_missing {
3378 attach::OnMissing::Error => attach::validate_local_path(raw),
3379 attach::OnMissing::Create => attach::validate_local_path_for_create(raw),
3380 };
3381 match resolved {
3382 Ok(canonical) => AttachSource::LocalFile { path: canonical },
3383 Err(e) => return Self::err_content(e),
3384 }
3385 }
3386 other => {
3387 return Self::err_content(McpError::new(
3388 ErrorCode::InvalidArgument,
3389 format!(
3390 "Unsupported attach kind '{other}'. Only 'local_file' is supported today; \
3391 'tcp' (remote hyperd) and 'grpc' (Data 360) are planned."
3392 ),
3393 ));
3394 }
3395 };
3396 let req = AttachRequest {
3397 alias: params.alias.clone(),
3398 source,
3399 writable,
3400 on_missing,
3401 };
3402 let registry = self.attachments_handle();
3403 let alias_for_probe = req.alias.clone();
3404 let result = self.with_engine(|engine| {
3405 let entry = registry.attach(engine, req.clone())?;
3406 let tables_visible = probe_table_count(engine, &alias_for_probe);
3411 Ok(json!({
3412 "alias": entry.alias,
3413 "kind": entry.source.kind_str(),
3414 "source": entry.source.to_json(),
3415 "writable": entry.writable,
3416 "tables_visible": tables_visible,
3417 }))
3418 });
3419 match result {
3420 Ok(val) => Self::ok_content(val),
3421 Err(e) => Self::err_content(e),
3422 }
3423 }
3424
3425 #[tool(
3427 description = "Detach a database previously registered with attach_database. No-op when the alias is unknown. Returns {detached: true/false}."
3428 )]
3429 fn detach_database(
3430 &self,
3431 Parameters(params): Parameters<DetachDatabaseParams>,
3432 ) -> Result<CallToolResult, rmcp::ErrorData> {
3433 let alias = params.alias.to_ascii_lowercase();
3438 if let Ok(watchers) = self.watchers.watchers.lock() {
3444 let conflict = watchers
3445 .values()
3446 .find(|h| h.target_db.as_deref() == Some(alias.as_str()));
3447 if let Some(h) = conflict {
3448 return Self::err_content(McpError::new(
3449 ErrorCode::InvalidArgument,
3450 format!(
3451 "cannot detach '{alias}': an active watcher on directory '{}' targets it. \
3452 Call unwatch_directory(\"{}\") first.",
3453 h.directory.display(),
3454 h.directory.display()
3455 ),
3456 ));
3457 }
3458 }
3459 let registry = self.attachments_handle();
3460 let result = self.with_engine(|engine| {
3461 let outcome = registry.detach(engine, &alias)?;
3462 if outcome {
3463 engine.clear_catalog_cache_for(&alias);
3467 }
3468 Ok(outcome)
3469 });
3470 match result {
3471 Ok(detached) => {
3472 Self::ok_content(json!({ "alias": params.alias, "detached": detached }))
3473 }
3474 Err(e) => Self::err_content(e),
3475 }
3476 }
3477
3478 #[tool(
3488 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."
3489 )]
3490 fn list_attached_databases(&self) -> Result<CallToolResult, rmcp::ErrorData> {
3491 let result = self.with_engine(|engine| {
3492 let entries = self.attachments.list();
3493 let attachments: Vec<Value> = entries
3494 .iter()
3495 .map(|entry| {
3496 let mut obj = entry.to_json();
3497 let tables_visible = probe_table_count(engine, &entry.alias);
3498 if let Some(map) = obj.as_object_mut() {
3499 map.insert("tables_visible".into(), json!(tables_visible));
3500 }
3501 obj
3502 })
3503 .collect();
3504 Ok(json!({ "attachments": attachments }))
3505 });
3506 match result {
3507 Ok(val) => Self::ok_content(val),
3508 Err(e) => Self::err_content(e),
3509 }
3510 }
3511
3512 #[tool(
3517 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."
3518 )]
3519 fn copy_query(
3520 &self,
3521 Parameters(params): Parameters<CopyQueryParams>,
3522 ) -> Result<CallToolResult, rmcp::ErrorData> {
3523 if let Err(e) = self.check_writable("copy_query") {
3524 return Self::err_content(e);
3525 }
3526 let mode = match params.mode.as_str() {
3527 "create" | "append" | "replace" => params.mode.clone(),
3528 other => {
3529 return Self::err_content(McpError::new(
3530 ErrorCode::InvalidArgument,
3531 format!(
3532 "copy_query mode '{other}' is not supported. Use 'create', 'append', or 'replace'."
3533 ),
3534 ));
3535 }
3536 };
3537 if !is_read_only_sql(¶ms.sql) {
3538 return Self::err_content(McpError::new(
3539 ErrorCode::SqlError,
3540 "copy_query's `sql` must be a read-only statement (SELECT / WITH / VALUES). \
3541 Use the execute tool for raw DDL/DML.",
3542 ));
3543 }
3544 let target_db_owned = params
3556 .target_database
3557 .as_deref()
3558 .filter(|s| !s.eq_ignore_ascii_case(LOCAL_ALIAS))
3559 .map(str::to_ascii_lowercase);
3560 let target_db = target_db_owned.as_deref();
3561 if let Some(alias) = target_db {
3562 match self.attachments.get(alias) {
3563 None => {
3564 return Self::err_content(McpError::new(
3565 ErrorCode::InvalidArgument,
3566 format!(
3567 "target_database '{alias}' is not attached. Call attach_database first."
3568 ),
3569 ));
3570 }
3571 Some(entry) if !entry.writable => {
3572 return Self::err_content(McpError::new(
3573 ErrorCode::InvalidArgument,
3574 format!(
3575 "target_database '{alias}' was attached read-only. Re-attach with writable:true to use it as a copy target."
3576 ),
3577 ));
3578 }
3579 Some(_) => {}
3580 }
3581 }
3582
3583 let temp_specs = params.temp_attach.clone().unwrap_or_default();
3586 let prepared_temps = match prepare_temp_attachments(&temp_specs, self.is_read_only()) {
3587 Ok(v) => v,
3588 Err(e) => return Self::err_content(e),
3589 };
3590
3591 let target_table = params.target_table.clone();
3592 let sql_body = params.sql.clone();
3593 let load_params = serde_json::to_string(&json!({
3594 "mode": mode,
3595 "target_database": params.target_database,
3596 "target_table": target_table,
3597 "sql": Self::fmt_sql(&sql_body),
3598 }))
3599 .ok();
3600
3601 let registry = self.attachments_handle();
3602 let result = self.with_engine(|engine| {
3603 let mut temp_aliases: Vec<String> = Vec::new();
3605 for req in &prepared_temps {
3606 match registry.attach(engine, req.clone()) {
3607 Ok(entry) => temp_aliases.push(entry.alias),
3608 Err(e) => {
3609 for alias in &temp_aliases {
3611 let _ = registry.detach(engine, alias);
3612 }
3613 return Err(e);
3614 }
3615 }
3616 }
3617
3618 let copy_outcome = perform_copy(engine, &mode, target_db, &target_table, &sql_body);
3621
3622 for alias in &temp_aliases {
3626 if let Err(e) = registry.detach(engine, alias) {
3627 tracing::warn!(
3628 alias = %alias,
3629 err = %e.message,
3630 "failed to detach temp attachment after copy_query",
3631 );
3632 }
3633 }
3634
3635 if copy_outcome.is_ok() && target_db.is_none() {
3646 let row_count = copy_outcome
3647 .as_ref()
3648 .ok()
3649 .and_then(|v| v.get("row_count").and_then(serde_json::Value::as_i64));
3650 self.after_ingest_catalog_update(
3651 engine,
3652 &target_table,
3653 "copy_query",
3654 load_params.as_deref(),
3655 row_count,
3656 target_db,
3657 );
3658 }
3659
3660 copy_outcome
3661 });
3662
3663 match result {
3664 Ok(outcome) => {
3665 if target_db.is_none() {
3667 self.notify_table_changed(&target_table);
3668 }
3669 self.notify_workspace_changed();
3670 if mode != "append" {
3671 self.notify_resource_list_changed();
3674 }
3675 Self::ok_content(outcome)
3676 }
3677 Err(e) => Self::err_content(e),
3678 }
3679 }
3680}
3681
3682#[prompt_router]
3685impl HyperMcpServer {
3686 #[prompt(
3688 name = "analyze-table",
3689 description = "Deep analysis of a single table: schema, sample, column stats, data quality"
3690 )]
3691 pub async fn analyze_table(
3692 &self,
3693 Parameters(args): Parameters<AnalyzeTableArgs>,
3694 ) -> Vec<PromptMessage> {
3695 let context = self.build_analyze_context(&args.table);
3696 vec![
3697 PromptMessage::new_text(
3698 PromptMessageRole::User,
3699 format!(
3700 "Analyze the `{}` table thoroughly.\n\n{}\n\nPlease:\n\
3701 1. Describe each column (what it likely represents based on name and sample values)\n\
3702 2. Compute basic statistics using the query tool: min/max/avg for numeric columns, distinct count and top values for text columns\n\
3703 3. Flag any data quality issues: unexpected NULLs, suspicious outliers, inconsistent formats\n\
3704 4. Summarize your findings in plain English",
3705 args.table, context
3706 ),
3707 ),
3708 PromptMessage::new_text(
3709 PromptMessageRole::Assistant,
3710 format!(
3711 "I'll analyze the `{}` table systematically. Let me start by examining the schema and sample, then run targeted queries for statistics and data quality.",
3712 args.table
3713 ),
3714 ),
3715 ]
3716 }
3717
3718 #[prompt(
3720 name = "compare-tables",
3721 description = "Compare two tables: schema alignment, common keys, JOIN opportunities"
3722 )]
3723 pub async fn compare_tables(
3724 &self,
3725 Parameters(args): Parameters<CompareTablesArgs>,
3726 ) -> Vec<PromptMessage> {
3727 let ctx_a = self.build_brief_context(&args.table_a);
3728 let ctx_b = self.build_brief_context(&args.table_b);
3729 vec![
3730 PromptMessage::new_text(
3731 PromptMessageRole::User,
3732 format!(
3733 "Compare these two tables:\n\n## Table A: `{}`\n{}\n\n## Table B: `{}`\n{}\n\nPlease:\n\
3734 1. Identify columns that appear in both tables (by name or semantic match)\n\
3735 2. Suggest likely JOIN keys and the JOIN type (inner, left, etc.)\n\
3736 3. Highlight schema differences (column types, nullability)\n\
3737 4. Propose 3-5 analytical queries that combine both tables and explain what each reveals",
3738 args.table_a, ctx_a, args.table_b, ctx_b
3739 ),
3740 ),
3741 PromptMessage::new_text(
3742 PromptMessageRole::Assistant,
3743 format!(
3744 "I'll compare `{}` and `{}` systematically — schema alignment first, then join keys, then analytical opportunities.",
3745 args.table_a, args.table_b
3746 ),
3747 ),
3748 ]
3749 }
3750
3751 #[prompt(
3753 name = "data-quality",
3754 description = "Systematic data quality assessment: NULL rates, duplicates, low cardinality, outliers"
3755 )]
3756 pub async fn data_quality(
3757 &self,
3758 Parameters(args): Parameters<DataQualityArgs>,
3759 ) -> Vec<PromptMessage> {
3760 let context = self.build_brief_context(&args.table);
3761 vec![
3762 PromptMessage::new_text(
3763 PromptMessageRole::User,
3764 format!(
3765 "Run a data quality assessment on the `{}` table.\n\n{}\n\nPlease use the query tool to check:\n\
3766 1. NULL rate per column — run SELECT COUNT(*) FILTER (WHERE col IS NULL) / COUNT(*) for each column\n\
3767 2. Duplicate rows — compare COUNT(*) vs COUNT(DISTINCT *) or use GROUP BY\n\
3768 3. Low-cardinality columns — columns with suspiciously few distinct values\n\
3769 4. Numeric outliers — values more than 3 stddev from the mean\n\
3770 5. Date sanity — future dates or impossibly old dates in date/timestamp columns\n\n\
3771 Summarize findings with severity (critical / warning / info) and suggest remediation for each issue.",
3772 args.table, context
3773 ),
3774 ),
3775 PromptMessage::new_text(
3776 PromptMessageRole::Assistant,
3777 format!(
3778 "I'll perform a systematic data quality assessment on `{}`. Let me run targeted queries for each check category.",
3779 args.table
3780 ),
3781 ),
3782 ]
3783 }
3784
3785 #[prompt(
3787 name = "suggest-queries",
3788 description = "Suggest analytical SQL queries for a table, optionally guided by a goal"
3789 )]
3790 pub async fn suggest_queries(
3791 &self,
3792 Parameters(args): Parameters<SuggestQueriesArgs>,
3793 ) -> Vec<PromptMessage> {
3794 let context = self.build_analyze_context(&args.table);
3795 let goal_section = match args.goal.as_deref() {
3796 Some(g) if !g.is_empty() => format!("\n\nSpecific goal: {g}"),
3797 _ => String::new(),
3798 };
3799 vec![
3800 PromptMessage::new_text(
3801 PromptMessageRole::User,
3802 format!(
3803 "Given the `{}` table:\n\n{}{}\n\nSuggest 5 analytical SQL queries that would be useful for exploring this data. \
3804 For each query, provide:\n\
3805 - A descriptive title\n\
3806 - The exact SQL (valid for Hyper / PostgreSQL-compatible syntax)\n\
3807 - One sentence explaining what insight it reveals\n\n\
3808 Prefer queries that use aggregations, GROUP BY, window functions, or CTEs to demonstrate the power of SQL analytics.",
3809 args.table, context, goal_section
3810 ),
3811 ),
3812 PromptMessage::new_text(
3813 PromptMessageRole::Assistant,
3814 format!(
3815 "Based on the schema and sample of `{}`, here are 5 analytical queries.",
3816 args.table
3817 ),
3818 ),
3819 ]
3820 }
3821}
3822
3823#[derive(Debug, Clone)]
3832pub enum ResourceBody {
3833 Json(Value),
3835 Text {
3838 mime_type: String,
3840 content: String,
3842 },
3843}
3844
3845impl ResourceBody {
3846 #[must_use]
3848 pub fn mime_type(&self) -> &str {
3849 match self {
3850 ResourceBody::Json(_) => "application/json",
3851 ResourceBody::Text { mime_type, .. } => mime_type,
3852 }
3853 }
3854
3855 #[must_use]
3858 pub fn to_text(&self) -> String {
3859 match self {
3860 ResourceBody::Json(v) => {
3861 serde_json::to_string_pretty(v).unwrap_or_else(|_| v.to_string())
3862 }
3863 ResourceBody::Text { content, .. } => content.clone(),
3864 }
3865 }
3866
3867 #[must_use]
3870 pub fn as_json(&self) -> Option<&Value> {
3871 match self {
3872 ResourceBody::Json(v) => Some(v),
3873 ResourceBody::Text { .. } => None,
3874 }
3875 }
3876}
3877
3878impl HyperMcpServer {
3879 pub fn resource_body_for_uri(&self, uri: &str) -> Result<Option<ResourceBody>, McpError> {
3898 if uri == "hyper://workspace" {
3899 return self
3900 .with_engine(super::engine::Engine::status)
3901 .map(|v| Some(ResourceBody::Json(v)));
3902 }
3903 if uri == "hyper://tables" {
3904 return self
3905 .with_engine(|engine| {
3906 engine
3907 .describe_tables()
3908 .map(|tables| json!({ "tables": tables }))
3909 })
3910 .map(|v| Some(ResourceBody::Json(v)));
3911 }
3912 if uri == "hyper://readme" {
3913 return self.build_readme_body().map(Some);
3914 }
3915 if uri == "hyper://schema/kv" {
3916 return Ok(Some(ResourceBody::Text {
3917 mime_type: "text/plain".into(),
3918 content: KV_SCHEMA_RESOURCE.to_string(),
3919 }));
3920 }
3921 if let Some(name) = uri
3922 .strip_prefix("hyper://tables/")
3923 .and_then(|rest| rest.strip_suffix("/schema"))
3924 {
3925 let name = name.to_string();
3926 return self
3927 .with_engine(|engine| {
3928 let tables = engine.describe_tables()?;
3929 tables
3930 .into_iter()
3931 .find(|t| t.get("name").and_then(|v| v.as_str()) == Some(name.as_str()))
3932 .ok_or_else(|| {
3933 McpError::new(
3934 ErrorCode::TableNotFound,
3935 format!("Table '{name}' does not exist"),
3936 )
3937 })
3938 })
3939 .map(|v| Some(ResourceBody::Json(v)));
3940 }
3941 if let Some(name) = uri
3942 .strip_prefix("hyper://tables/")
3943 .and_then(|rest| rest.strip_suffix("/sample"))
3944 {
3945 let name = name.to_string();
3946 return self
3947 .with_engine(|engine| engine.sample_table(&name, TABLE_SAMPLE_ROWS))
3948 .map(|v| Some(ResourceBody::Json(v)));
3949 }
3950 if let Some(name) = uri
3951 .strip_prefix("hyper://tables/")
3952 .and_then(|rest| rest.strip_suffix("/csv-sample"))
3953 {
3954 let name = name.to_string();
3955 return self.build_csv_sample_body(&name).map(Some);
3956 }
3957 if let Some(name) = uri
3958 .strip_prefix("hyper://queries/")
3959 .and_then(|rest| rest.strip_suffix("/definition"))
3960 {
3961 return self.build_saved_query_definition(name).map(Some);
3962 }
3963 if let Some(name) = uri
3964 .strip_prefix("hyper://queries/")
3965 .and_then(|rest| rest.strip_suffix("/result"))
3966 {
3967 return self.build_saved_query_result(name).map(Some);
3968 }
3969 Ok(None)
3970 }
3971
3972 fn build_saved_query_definition(&self, name: &str) -> Result<ResourceBody, McpError> {
3976 let store = Arc::clone(&self.saved_queries);
3977 let name = name.to_string();
3978 let query = self.with_saved_query_store(|engine| store.get(engine, &name))?;
3979 match query {
3980 Some(q) => Ok(ResourceBody::Json(q.to_json())),
3981 None => Err(McpError::new(
3982 ErrorCode::TableNotFound,
3983 format!("No saved query named '{name}'"),
3984 )),
3985 }
3986 }
3987
3988 fn build_saved_query_result(&self, name: &str) -> Result<ResourceBody, McpError> {
3993 let store = Arc::clone(&self.saved_queries);
3994 let name_owned = name.to_string();
3995 let query = self
3996 .with_saved_query_store(|engine| store.get(engine, &name_owned))?
3997 .ok_or_else(|| {
3998 McpError::new(
3999 ErrorCode::TableNotFound,
4000 format!("No saved query named '{name_owned}'"),
4001 )
4002 })?;
4003 let sql = query.sql.clone();
4004 let body = self.with_engine(|engine| {
4005 let timer = crate::stats::StatsTimer::start();
4006 let rows = engine.execute_query_to_json(&sql)?;
4007 let elapsed = timer.elapsed_ms();
4008 let result_size = serde_json::to_string(&rows).map_or(0, |s| s.len() as u64);
4009 let stats = crate::stats::QueryStats {
4010 operation: "saved_query".into(),
4011 rows_returned: rows.len() as u64,
4012 rows_scanned: 0,
4013 elapsed_ms: elapsed,
4014 result_size_bytes: result_size,
4015 tables_touched: vec![],
4016 };
4017 Ok(json!({
4018 "name": query.name,
4019 "sql": Self::fmt_sql(&query.sql),
4020 "result": rows,
4021 "stats": stats.to_json(),
4022 }))
4023 })?;
4024 Ok(ResourceBody::Json(body))
4025 }
4026
4027 #[must_use]
4035 pub fn list_resource_uris(&self) -> Vec<String> {
4036 let mut uris = vec![
4037 "hyper://workspace".to_string(),
4038 "hyper://tables".to_string(),
4039 "hyper://readme".to_string(),
4040 "hyper://schema/kv".to_string(),
4041 ];
4042 if let Ok(tables) = self.with_engine(super::engine::Engine::describe_tables) {
4043 for table in tables {
4047 if let Some(name) = table.get("name").and_then(|v| v.as_str()) {
4048 uris.push(format!("hyper://tables/{name}/schema"));
4049 uris.push(format!("hyper://tables/{name}/sample"));
4050 uris.push(format!("hyper://tables/{name}/csv-sample"));
4051 }
4052 }
4053 }
4054 let store = Arc::clone(&self.saved_queries);
4055 if let Ok(saved) = self.with_saved_query_store(|engine| store.list(engine)) {
4056 for q in saved {
4057 uris.push(format!("hyper://queries/{}/definition", q.name));
4058 uris.push(format!("hyper://queries/{}/result", q.name));
4059 }
4060 }
4061 uris
4062 }
4063
4064 fn build_readme_body(&self) -> Result<ResourceBody, McpError> {
4072 let status = self.with_engine(super::engine::Engine::status)?;
4073 let tables = self
4074 .with_engine(super::engine::Engine::describe_tables)
4075 .unwrap_or_default();
4076
4077 let workspace_mode = status
4078 .get("workspace_mode")
4079 .and_then(|v| v.as_str())
4080 .unwrap_or("unknown");
4081 let workspace_path = status
4082 .get("workspace_path")
4083 .and_then(|v| v.as_str())
4084 .unwrap_or("");
4085 let read_only = status
4086 .get("read_only")
4087 .and_then(serde_json::Value::as_bool)
4088 .unwrap_or(false);
4089 let table_count = tables.len();
4090
4091 let mut md = String::new();
4092 md.push_str("# HyperDB workspace\n\n");
4093 let _ = writeln!(
4094 md,
4095 "- Mode: **{workspace_mode}**{}\n",
4096 if read_only { " (read-only)" } else { "" }
4097 );
4098 if !workspace_path.is_empty() {
4099 let _ = writeln!(md, "- Path: `{workspace_path}`\n");
4100 }
4101 let _ = write!(md, "- Tables: **{table_count}**\n\n");
4102
4103 if tables.is_empty() {
4104 md.push_str(
4105 "_No tables loaded yet._ Use the `load_file` or `load_data` tools to \
4106 ingest CSV / JSON / Parquet / Arrow IPC data; call `inspect_file` \
4107 first if you're unsure of the schema.\n",
4108 );
4109 } else {
4110 md.push_str("## Tables\n\n");
4111 md.push_str("| Table | Rows | Columns |\n");
4112 md.push_str("|---|---:|---|\n");
4113 for t in &tables {
4114 let name = t.get("name").and_then(|v| v.as_str()).unwrap_or("?");
4115 let rows = t
4116 .get("row_count")
4117 .and_then(serde_json::Value::as_i64)
4118 .unwrap_or(0);
4119 let cols: Vec<String> = t
4120 .get("columns")
4121 .and_then(|v| v.as_array())
4122 .map(|arr| {
4123 arr.iter()
4124 .filter_map(|c| {
4125 let n = c.get("name")?.as_str()?;
4126 let ty = c.get("type")?.as_str()?;
4127 Some(format!("`{n}` {ty}"))
4128 })
4129 .collect()
4130 })
4131 .unwrap_or_default();
4132 let _ = writeln!(md, "| `{name}` | {rows} | {} |\n", cols.join(", "));
4133 }
4134 md.push('\n');
4135 md.push_str("## Related resources\n\n");
4136 for t in &tables {
4137 if let Some(name) = t.get("name").and_then(|v| v.as_str()) {
4138 let _ = write!(md, "- `hyper://tables/{name}/schema` — JSON schema and row count\n\
4139 - `hyper://tables/{name}/sample` — first {TABLE_SAMPLE_ROWS} rows as JSON\n\
4140 - `hyper://tables/{name}/csv-sample` — first {TABLE_CSV_SAMPLE_ROWS} rows as CSV\n");
4141 }
4142 }
4143 md.push('\n');
4144 }
4145
4146 md.push_str(
4147 "## Tool hints\n\n\
4148 - `query(sql)` — read-only SQL (SELECT / WITH / EXPLAIN / SHOW / VALUES).\n\
4149 - `execute(sql)` — DDL/DML (disabled in read-only mode).\n\
4150 - `sample(table, n)` — configurable row sample; the fixed-size\n \
4151 `hyper://tables/{name}/sample` resource uses n=5.\n\
4152 - `inspect_file(path)` — dry-run schema inference before loading.\n\
4153 - `chart(sql, chart_type, ...)` — render a PNG/SVG from a query.\n\
4154 - `export(sql|table, path, format)` — write to CSV / Parquet / Arrow IPC / .hyper.\n",
4155 );
4156
4157 Ok(ResourceBody::Text {
4158 mime_type: "text/markdown".into(),
4159 content: md,
4160 })
4161 }
4162
4163 fn build_csv_sample_body(&self, table: &str) -> Result<ResourceBody, McpError> {
4167 let sample =
4168 self.with_engine(|engine| engine.sample_table(table, TABLE_CSV_SAMPLE_ROWS))?;
4169
4170 let header: Vec<String> = sample
4174 .get("schema")
4175 .and_then(|v| v.as_array())
4176 .map(|cols| {
4177 cols.iter()
4178 .filter_map(|c| c.get("name").and_then(|n| n.as_str()).map(String::from))
4179 .collect()
4180 })
4181 .filter(|v: &Vec<String>| !v.is_empty())
4182 .or_else(|| {
4183 sample
4184 .get("rows")
4185 .and_then(|v| v.as_array())
4186 .and_then(|rows| rows.first())
4187 .and_then(|r| r.as_object())
4188 .map(|o| o.keys().cloned().collect())
4189 })
4190 .unwrap_or_default();
4191
4192 let mut wtr = csv::Writer::from_writer(Vec::<u8>::new());
4193 if !header.is_empty() {
4194 wtr.write_record(&header).map_err(|e| {
4195 McpError::new(
4196 ErrorCode::InternalError,
4197 format!("Failed to write CSV header: {e}"),
4198 )
4199 })?;
4200 }
4201 if let Some(rows) = sample.get("rows").and_then(|v| v.as_array()) {
4202 for row in rows {
4203 let record: Vec<String> = header
4204 .iter()
4205 .map(|col| row.get(col).map(value_to_csv_cell).unwrap_or_default())
4206 .collect();
4207 wtr.write_record(&record).map_err(|e| {
4208 McpError::new(
4209 ErrorCode::InternalError,
4210 format!("Failed to write CSV row: {e}"),
4211 )
4212 })?;
4213 }
4214 }
4215 let bytes = wtr.into_inner().map_err(|e| {
4216 McpError::new(
4217 ErrorCode::InternalError,
4218 format!("Failed to finalize CSV: {e}"),
4219 )
4220 })?;
4221 let content = String::from_utf8(bytes).map_err(|e| {
4222 McpError::new(
4223 ErrorCode::InternalError,
4224 format!("CSV produced invalid UTF-8: {e}"),
4225 )
4226 })?;
4227
4228 Ok(ResourceBody::Text {
4229 mime_type: "text/csv".into(),
4230 content,
4231 })
4232 }
4233
4234 fn build_analyze_context(&self, table: &str) -> String {
4237 match self.with_engine(|engine| engine.sample_table(table, 10)) {
4238 Ok(sample) => format!(
4239 "Schema and sample:\n```json\n{}\n```",
4240 serde_json::to_string_pretty(&sample).unwrap_or_else(|_| sample.to_string())
4241 ),
4242 Err(e) => format!("(Could not load table context: {e})"),
4243 }
4244 }
4245
4246 fn build_brief_context(&self, table: &str) -> String {
4248 match self.with_engine(|engine| engine.sample_table(table, 5)) {
4249 Ok(sample) => format!(
4250 "```json\n{}\n```",
4251 serde_json::to_string_pretty(&sample).unwrap_or_else(|_| sample.to_string())
4252 ),
4253 Err(e) => format!("(Could not load table context: {e})"),
4254 }
4255 }
4256}
4257
4258#[tool_handler]
4261#[prompt_handler]
4262impl ServerHandler for HyperMcpServer {
4263 fn get_info(&self) -> ServerInfo {
4264 let sql_dialect = "\n\
4265\n\
4266SQL DIALECT — Salesforce Data Cloud SQL (PostgreSQL-compatible with extensions).\n\
4267Key differences from standard PostgreSQL an LLM should know:\n\
4268\n\
4269TYPES\n\
4270- Supported: SMALLINT, INTEGER/INT, BIGINT, REAL/FLOAT4, DOUBLE PRECISION/FLOAT8,\n\
4271 NUMERIC(p,s)/DECIMAL(p,s), BOOLEAN, TEXT, CHAR(n), VARCHAR(n), BYTES,\n\
4272 DATE, TIME, TIMESTAMP, TIMESTAMPTZ, INTERVAL, and arrays of any atomic type\n\
4273- NUMERIC precision > 18 requires .hyper file format version 3 (default in this MCP)\n\
4274- No SERIAL / BIGSERIAL / UUID / JSON / JSONB / geometry types\n\
4275\n\
4276SELECT / QUERY\n\
4277- LIMIT / OFFSET work as in PostgreSQL; TOP N is also accepted\n\
4278- LATERAL is optional: subqueries in FROM always see preceding FROM items implicitly\n\
4279- DISTINCT ON (expr, ...) is supported\n\
4280- FROM clause is optional (can evaluate expressions without a table)\n\
4281- Function calls may appear directly in the FROM list\n\
4282- information_schema and pg_catalog do NOT exist; use the describe/sample tools\n\
4283\n\
4284GROUP BY / AGGREGATION\n\
4285- GROUPING SETS, ROLLUP, CUBE all supported\n\
4286- GROUP BY DISTINCT removes duplicate grouping sets before processing\n\
4287- FILTER (WHERE ...) clause supported on aggregate calls\n\
4288- Ordered-set aggregates: MODE(), PERCENTILE_CONT(), PERCENTILE_DISC() with WITHIN GROUP (ORDER BY ...)\n\
4289- APPROX_COUNT_DISTINCT() for fast approximate cardinality\n\
4290- GROUPING() function identifies which columns are aggregated in GROUPING SETS\n\
4291\n\
4292WINDOW FUNCTIONS\n\
4293- Standard: row_number, rank, dense_rank, percent_rank, cume_dist, ntile, lag, lead,\n\
4294 first_value, last_value, nth_value\n\
4295- Hyper extension: modified_rank() — like rank() but assigns the LOWEST rank on ties\n\
4296- IGNORE NULLS / RESPECT NULLS supported on last_value only\n\
4297- nth_value supports FROM FIRST / FROM LAST\n\
4298- Frame modes: ROWS, RANGE, GROUPS; EXCLUDE CURRENT ROW / GROUP / TIES / NO OTHERS\n\
4299- Window-specific functions do NOT support DISTINCT or ORDER BY in their argument list\n\
4300\n\
4301SET-RETURNING FUNCTIONS (usable in FROM)\n\
4302- unnest(array) — expands an array to rows; supports WITH ORDINALITY\n\
4303- generate_series(start, stop [, step]) — numeric and datetime variants\n\
4304- external(path, format => '...') — reads Parquet, CSV, Iceberg etc. directly from files\n\
4305\n\
4306SET OPERATORS\n\
4307- UNION, INTERSECT, EXCEPT all supported; INTERSECT binds tighter than UNION/EXCEPT\n\
4308- ORDER BY and LIMIT/OFFSET can appear on parenthesized sub-expressions or the final result\n\
4309\n\
4310CTEs\n\
4311- WITH and WITH RECURSIVE both supported\n\
4312- CTEs evaluate once per query execution even if referenced multiple times\n\
4313\n\
4314IDENTIFIERS\n\
4315- Unquoted identifiers are folded to lowercase; double-quote to preserve case or use special chars\n\
4316- Quote names containing uppercase letters, digits at the start, or special characters\n\
4317\n\
4318NOT AVAILABLE IN HYPER (Data 360 / Data Cloud-only features)\n\
4319- AI functions: AI_CLASSIFY, AI_SENTIMENT, and other Data Cloud AI scalar functions\n\
4320- Data Cloud federation / streaming-specific functions\n\
4321\n\
4322Full SQL reference: https://developer.salesforce.com/docs/data/data-cloud-query-guide/references/dc-sql-reference";
4323
4324 let header = if self.read_only {
4325 "HyperDB MCP (read-only): SQL analytics for LLM workflows. Query existing tables, \
4326 sample data, export results. Mutating operations are disabled. \
4327 Call get_readme for a concise tool index, parameter rules, and usage examples."
4328 } else {
4329 "HyperDB MCP: instant SQL analytics for LLM workflows. Load data (CSV, JSON, Parquet, \
4330 Arrow IPC, Apache Iceberg), query with SQL, export results (Parquet, Iceberg, Arrow IPC, \
4331 CSV, Hyper). Use query for SELECT and execute for DDL/DML. \
4332 Call get_readme for a concise tool index, parameter rules, and usage examples."
4333 };
4334 let instructions = format!("{header}{sql_dialect}");
4335 let mut server_info = Implementation::default();
4336 server_info.name = "HyperDB".into();
4337 server_info.title = Some("HyperDB — Hyper SQL Analytics".into());
4338 server_info.version = env!("CARGO_PKG_VERSION").into();
4339 server_info.description = Some(
4340 "MCP server for Tableau Hyper: instant SQL analytics over \
4341 CSV, JSON, Parquet, Arrow IPC, and Apache Iceberg with schema inference, \
4342 partial schema overrides, full-file numeric widening, and \
4343 dry-run file inspection. SQL dialect is PostgreSQL-compatible with \
4344 extensions (Salesforce Data Cloud SQL). Full SQL reference: \
4345 https://developer.salesforce.com/docs/data/data-cloud-query-guide/references/dc-sql-reference/data-cloud-sql-context.html"
4346 .into(),
4347 );
4348
4349 let mut info = ServerInfo::default();
4350 info.instructions = Some(instructions);
4351 info.server_info = server_info;
4352 info.capabilities = ServerCapabilities::builder()
4353 .enable_tools()
4354 .enable_prompts()
4355 .enable_resources()
4356 .enable_resources_subscribe()
4361 .enable_resources_list_changed()
4362 .build();
4363 info
4364 }
4365
4366 async fn initialize(
4367 &self,
4368 request: InitializeRequestParams,
4369 context: RequestContext<RoleServer>,
4370 ) -> Result<InitializeResult, rmcp::ErrorData> {
4371 let name = &request.client_info.name;
4372 let version = &request.client_info.version;
4373 let label = if version.is_empty() {
4374 name.clone()
4375 } else {
4376 format!("{name} {version}")
4377 };
4378 if let Ok(mut guard) = self.client_name.lock() {
4379 *guard = Some(label);
4380 }
4381 context.peer.set_peer_info(request);
4382 Ok(self.get_info())
4383 }
4384
4385 async fn subscribe(
4394 &self,
4395 request: SubscribeRequestParams,
4396 context: RequestContext<RoleServer>,
4397 ) -> Result<(), rmcp::ErrorData> {
4398 self.subscriptions.subscribe(&request.uri, context.peer);
4399 Ok(())
4400 }
4401
4402 async fn unsubscribe(
4407 &self,
4408 request: UnsubscribeRequestParams,
4409 context: RequestContext<RoleServer>,
4410 ) -> Result<(), rmcp::ErrorData> {
4411 self.subscriptions.unsubscribe(&request.uri, &context.peer);
4412 Ok(())
4413 }
4414
4415 async fn list_resources(
4421 &self,
4422 _request: Option<PaginatedRequestParams>,
4423 _context: RequestContext<RoleServer>,
4424 ) -> Result<ListResourcesResult, rmcp::ErrorData> {
4425 let mut resources = vec![
4426 RawResource {
4427 uri: "hyper://workspace".into(),
4428 name: "Workspace Info".into(),
4429 title: Some("Hyper Workspace".into()),
4430 description: Some("Workspace mode, table count, total rows, disk usage".into()),
4431 mime_type: Some("application/json".into()),
4432 size: None,
4433 icons: None,
4434 meta: None,
4435 }
4436 .no_annotation(),
4437 RawResource {
4438 uri: "hyper://tables".into(),
4439 name: "All Tables".into(),
4440 title: Some("All Tables".into()),
4441 description: Some("List of all tables with column schemas and row counts".into()),
4442 mime_type: Some("application/json".into()),
4443 size: None,
4444 icons: None,
4445 meta: None,
4446 }
4447 .no_annotation(),
4448 RawResource {
4449 uri: "hyper://readme".into(),
4450 name: "Workspace Readme".into(),
4451 title: Some("HyperDB workspace readme".into()),
4452 description: Some(
4453 "Markdown overview of the workspace: tables, row counts, related \
4454 resources, and tool hints for LLMs orienting themselves."
4455 .into(),
4456 ),
4457 mime_type: Some("text/markdown".into()),
4458 size: None,
4459 icons: None,
4460 meta: None,
4461 }
4462 .no_annotation(),
4463 RawResource {
4464 uri: "hyper://schema/kv".into(),
4465 name: "KV store schema".into(),
4466 title: Some("Key-value scratchpad schema".into()),
4467 description: Some(
4468 "Schema of the _hyperdb_kv_store table backing the kv_* tools, the \
4469 ephemeral-vs-persistent durability rule, and the LEFT JOIN enrichment \
4470 pattern for joining KV metadata onto analytical tables."
4471 .into(),
4472 ),
4473 mime_type: Some("text/plain".into()),
4474 size: None,
4475 icons: None,
4476 meta: None,
4477 }
4478 .no_annotation(),
4479 ];
4480
4481 if let Ok(tables) = self.with_engine(super::engine::Engine::describe_tables) {
4482 for table in tables {
4486 if let Some(name) = table.get("name").and_then(|v| v.as_str()) {
4487 let row_count = table
4488 .get("row_count")
4489 .and_then(serde_json::Value::as_i64)
4490 .unwrap_or(0);
4491 resources.push(
4492 RawResource {
4493 uri: format!("hyper://tables/{name}/schema"),
4494 name: format!("Schema of {name}"),
4495 title: Some(format!("{name} schema")),
4496 description: Some(format!(
4497 "Column schema and row count ({row_count} rows) for table '{name}'"
4498 )),
4499 mime_type: Some("application/json".into()),
4500 size: None,
4501 icons: None,
4502 meta: None,
4503 }
4504 .no_annotation(),
4505 );
4506 resources.push(
4507 RawResource {
4508 uri: format!("hyper://tables/{name}/sample"),
4509 name: format!("Sample of {name}"),
4510 title: Some(format!("{name} sample (JSON)")),
4511 description: Some(format!(
4512 "First {TABLE_SAMPLE_ROWS} rows of '{name}' as JSON, with schema"
4513 )),
4514 mime_type: Some("application/json".into()),
4515 size: None,
4516 icons: None,
4517 meta: None,
4518 }
4519 .no_annotation(),
4520 );
4521 resources.push(
4522 RawResource {
4523 uri: format!("hyper://tables/{name}/csv-sample"),
4524 name: format!("CSV sample of {name}"),
4525 title: Some(format!("{name} sample (CSV)")),
4526 description: Some(format!(
4527 "First {TABLE_CSV_SAMPLE_ROWS} rows of '{name}' as CSV"
4528 )),
4529 mime_type: Some("text/csv".into()),
4530 size: None,
4531 icons: None,
4532 meta: None,
4533 }
4534 .no_annotation(),
4535 );
4536 }
4537 }
4538 }
4539
4540 let store = Arc::clone(&self.saved_queries);
4541 if let Ok(saved) = self.with_saved_query_store(|engine| store.list(engine)) {
4542 for q in saved {
4543 let desc = q
4544 .description
4545 .clone()
4546 .unwrap_or_else(|| format!("Saved read-only SQL query '{}'", q.name));
4547 resources.push(
4548 RawResource {
4549 uri: format!("hyper://queries/{}/definition", q.name),
4550 name: format!("Query: {}", q.name),
4551 title: Some(format!("{} (definition)", q.name)),
4552 description: Some(format!("SQL + metadata for saved query '{}'", q.name)),
4553 mime_type: Some("application/json".into()),
4554 size: None,
4555 icons: None,
4556 meta: None,
4557 }
4558 .no_annotation(),
4559 );
4560 resources.push(
4561 RawResource {
4562 uri: format!("hyper://queries/{}/result", q.name),
4563 name: format!("Result: {}", q.name),
4564 title: Some(format!("{} (result)", q.name)),
4565 description: Some(format!("{desc} — re-runs on every read")),
4566 mime_type: Some("application/json".into()),
4567 size: None,
4568 icons: None,
4569 meta: None,
4570 }
4571 .no_annotation(),
4572 );
4573 }
4574 }
4575
4576 Ok(ListResourcesResult {
4577 resources,
4578 next_cursor: None,
4579 meta: None,
4580 })
4581 }
4582
4583 async fn list_resource_templates(
4586 &self,
4587 _request: Option<PaginatedRequestParams>,
4588 _context: RequestContext<RoleServer>,
4589 ) -> Result<ListResourceTemplatesResult, rmcp::ErrorData> {
4590 let templates = vec![
4591 RawResourceTemplate {
4592 uri_template: "hyper://tables/{name}/schema".into(),
4593 name: "Table Schema".into(),
4594 title: Some("Table Schema".into()),
4595 description: Some(
4596 "Column schema, types, nullability, and row count for a named table".into(),
4597 ),
4598 mime_type: Some("application/json".into()),
4599 icons: None,
4600 }
4601 .no_annotation(),
4602 RawResourceTemplate {
4603 uri_template: "hyper://tables/{name}/sample".into(),
4604 name: "Table Sample (JSON)".into(),
4605 title: Some("Table Sample".into()),
4606 description: Some(
4607 "First few rows of a named table as JSON, with schema. For a \
4608 configurable row count use the `sample` tool instead."
4609 .into(),
4610 ),
4611 mime_type: Some("application/json".into()),
4612 icons: None,
4613 }
4614 .no_annotation(),
4615 RawResourceTemplate {
4616 uri_template: "hyper://tables/{name}/csv-sample".into(),
4617 name: "Table Sample (CSV)".into(),
4618 title: Some("Table Sample (CSV)".into()),
4619 description: Some(
4620 "First few rows of a named table as CSV, header-first, for \
4621 spreadsheet and Pandas consumers."
4622 .into(),
4623 ),
4624 mime_type: Some("text/csv".into()),
4625 icons: None,
4626 }
4627 .no_annotation(),
4628 RawResourceTemplate {
4629 uri_template: "hyper://queries/{name}/definition".into(),
4630 name: "Saved Query Definition".into(),
4631 title: Some("Saved Query Definition".into()),
4632 description: Some(
4633 "Stored SQL plus metadata (description, created_at) for a saved \
4634 query registered via the `save_query` tool."
4635 .into(),
4636 ),
4637 mime_type: Some("application/json".into()),
4638 icons: None,
4639 }
4640 .no_annotation(),
4641 RawResourceTemplate {
4642 uri_template: "hyper://queries/{name}/result".into(),
4643 name: "Saved Query Result".into(),
4644 title: Some("Saved Query Result".into()),
4645 description: Some(
4646 "Live result of a saved query. The stored SQL re-runs on every \
4647 resource read — no caching, always fresh."
4648 .into(),
4649 ),
4650 mime_type: Some("application/json".into()),
4651 icons: None,
4652 }
4653 .no_annotation(),
4654 ];
4655 Ok(ListResourceTemplatesResult {
4656 resource_templates: templates,
4657 next_cursor: None,
4658 meta: None,
4659 })
4660 }
4661
4662 async fn read_resource(
4667 &self,
4668 request: ReadResourceRequestParams,
4669 _context: RequestContext<RoleServer>,
4670 ) -> Result<ReadResourceResult, rmcp::ErrorData> {
4671 let uri = &request.uri;
4672 let (mime_type, text) = match self.resource_body_for_uri(uri) {
4673 Ok(Some(body)) => (body.mime_type().to_string(), body.to_text()),
4674 Ok(None) => {
4675 return Err(rmcp::ErrorData::invalid_params(
4676 format!("Unknown resource URI: {uri}"),
4677 None,
4678 ));
4679 }
4680 Err(e) => {
4681 let err_val = serde_json::to_value(&e).unwrap_or(Value::String(e.to_string()));
4684 let text =
4685 serde_json::to_string_pretty(&json!({ "error": err_val })).unwrap_or_default();
4686 ("application/json".into(), text)
4687 }
4688 };
4689
4690 Ok(ReadResourceResult::new(vec![
4691 ResourceContents::TextResourceContents {
4692 uri: uri.clone(),
4693 mime_type: Some(mime_type),
4694 text,
4695 meta: None,
4696 },
4697 ]))
4698 }
4699}
4700
4701fn validate_execute_batch(stmts: &[String]) -> Result<(), McpError> {
4725 use crate::engine::strip_leading_sql_comments;
4726
4727 if stmts.is_empty() {
4728 return Err(McpError::new(
4729 ErrorCode::InvalidArgument,
4730 "`sql` must be a non-empty array of SQL statements.",
4731 )
4732 .with_suggestion("Pass at least one statement: `sql: [\"INSERT INTO t VALUES (1)\"]`."));
4733 }
4734
4735 let mut has_schema_change = false;
4736 let mut has_data_mutation = false;
4737
4738 for (idx, stmt) in stmts.iter().enumerate() {
4739 if strip_leading_sql_comments(stmt).trim().is_empty() {
4740 return Err(McpError::new(
4741 ErrorCode::InvalidArgument,
4742 format!("`sql[{idx}]` is empty or contains only whitespace/comments."),
4743 )
4744 .with_suggestion("Remove the empty element or replace it with a real statement."));
4745 }
4746 match classify_statement(stmt) {
4755 StatementKind::ReadOnly => {
4756 return Err(McpError::new(
4757 ErrorCode::SqlError,
4758 format!(
4759 "`sql[{idx}]` is a read-only statement; the `execute` tool is for DDL/DML."
4760 ),
4761 )
4762 .with_suggestion(
4763 "Use the `query` tool for SELECT/WITH/EXPLAIN/SHOW/VALUES. To read-then-write atomically, fold the read into the write (e.g. `UPDATE … FROM (SELECT …)` or `INSERT … SELECT …`).",
4764 ));
4765 }
4766 StatementKind::TransactionControl => {
4767 return Err(McpError::new(
4775 ErrorCode::InvalidArgument,
4776 format!(
4777 "`sql[{idx}]` is a transaction-control statement (BEGIN / COMMIT / ROLLBACK / SAVEPOINT); these are not allowed in `execute` batches."
4778 ),
4779 )
4780 .with_suggestion(
4781 "The `execute` tool manages the transaction for you — multi-element arrays already run inside BEGIN/COMMIT. Just pass the DML statements you want to run atomically.",
4782 ));
4783 }
4784 StatementKind::Ddl => has_schema_change = true,
4785 StatementKind::Dml => has_data_mutation = true,
4786 StatementKind::Other => {}
4787 }
4788 }
4789
4790 if has_schema_change && has_data_mutation {
4791 return Err(McpError::new(
4792 ErrorCode::InvalidArgument,
4793 "Cannot mix DDL (CREATE/DROP/ALTER/TRUNCATE/RENAME) with DML (INSERT/UPDATE/DELETE/COPY/MERGE) in one `execute` batch.",
4794 )
4795 .with_suggestion(
4796 "Hyper aborts such transactions with SQLSTATE 0A000. Issue DDL in a separate `execute` call from DML — DDL singletons run as their own auto-commit unit.",
4797 ));
4798 }
4799
4800 if has_schema_change && stmts.len() > 1 {
4801 return Err(McpError::new(
4802 ErrorCode::InvalidArgument,
4803 "Multi-statement DDL batches are not supported.",
4804 )
4805 .with_suggestion(
4806 "Hyper auto-commits CREATE/DROP/ALTER even inside a transaction, so wrapping multiple DDL statements in one `execute` call cannot guarantee atomicity. Issue each DDL as its own single-element `execute` call.",
4807 ));
4808 }
4809
4810 Ok(())
4811}
4812
4813fn value_to_csv_cell(v: &Value) -> String {
4819 match v {
4820 Value::Null => String::new(),
4821 Value::Bool(b) => b.to_string(),
4822 Value::Number(n) => n.to_string(),
4823 Value::String(s) => s.clone(),
4824 _ => v.to_string(),
4825 }
4826}
4827
4828fn detect_format(data: &str) -> String {
4831 let trimmed = data.trim_start();
4832 if trimmed.starts_with('[') || trimmed.starts_with('{') {
4833 "json".into()
4834 } else {
4835 "csv".into()
4836 }
4837}
4838
4839fn rand_suffix() -> String {
4843 use std::time::{SystemTime, UNIX_EPOCH};
4844 let t = SystemTime::now()
4845 .duration_since(UNIX_EPOCH)
4846 .unwrap_or_default();
4847 format!("{}", t.as_nanos() % 1_000_000_000)
4848}
4849
4850fn qualified_name(engine: &Engine, db: Option<&str>, table: &str) -> String {
4861 let alias = db.map_or_else(|| engine.primary_db_name(), str::to_string);
4862 let escaped_alias = alias.replace('"', "\"\"");
4863 let escaped_table = table.replace('"', "\"\"");
4864 format!("\"{escaped_alias}\".\"public\".\"{escaped_table}\"")
4865}
4866
4867fn target_exists(engine: &Engine, db: Option<&str>, table: &str) -> Result<bool, McpError> {
4872 let sql = format!(
4873 "SELECT 1 FROM {} LIMIT 0",
4874 qualified_name(engine, db, table)
4875 );
4876 match engine.execute_query_to_json(&sql) {
4877 Ok(_) => Ok(true),
4878 Err(e) => {
4879 let m = e.message.to_lowercase();
4880 let missing = m.contains("does not exist")
4881 || m.contains("undefined table")
4882 || e.message.contains("42P01");
4883 if missing {
4884 Ok(false)
4885 } else {
4886 Err(e)
4887 }
4888 }
4889 }
4890}
4891
4892fn count_rows(engine: &Engine, db: Option<&str>, table: &str) -> i64 {
4897 let sql = format!(
4898 "SELECT COUNT(*) AS cnt FROM {}",
4899 qualified_name(engine, db, table)
4900 );
4901 engine
4902 .execute_query_to_json(&sql)
4903 .ok()
4904 .and_then(|rows| {
4905 rows.first()
4906 .and_then(|r| r.get("cnt").and_then(serde_json::Value::as_i64))
4907 })
4908 .unwrap_or(0)
4909}
4910
4911fn probe_table_count(engine: &Engine, alias: &str) -> Value {
4915 let escaped_alias = alias.replace('"', "\"\"");
4916 let sql = format!(
4917 "SELECT COUNT(*) AS cnt FROM \"{escaped_alias}\".pg_catalog.pg_tables WHERE schemaname = 'public'"
4918 );
4919 match engine.execute_query_to_json(&sql) {
4920 Ok(rows) => rows
4921 .first()
4922 .and_then(|r| r.get("cnt").and_then(serde_json::Value::as_i64))
4923 .map_or(Value::Null, |n| json!(n)),
4924 Err(_) => Value::Null,
4925 }
4926}
4927
4928fn prepare_temp_attachments(
4932 specs: &[AttachSpec],
4933 read_only: bool,
4934) -> Result<Vec<AttachRequest>, McpError> {
4935 let mut out = Vec::with_capacity(specs.len());
4936 for spec in specs {
4937 let writable = spec.writable.unwrap_or(false);
4938 if writable && read_only {
4939 return Err(McpError::new(
4940 ErrorCode::ReadOnlyViolation,
4941 format!(
4942 "temp_attach for alias '{}' requested writable:true but the server is --read-only",
4943 spec.alias
4944 ),
4945 ));
4946 }
4947 let on_missing = attach::OnMissing::parse(spec.on_missing.as_deref())?;
4948 if on_missing == attach::OnMissing::Create && !writable {
4949 return Err(McpError::new(
4950 ErrorCode::InvalidArgument,
4951 format!(
4952 "temp_attach alias '{}' has on_missing='create' but writable is not true — \
4953 an empty .hyper file that cannot be written to cannot be populated.",
4954 spec.alias
4955 ),
4956 ));
4957 }
4958 let source = match spec.kind.as_str() {
4959 "local_file" => {
4960 let Some(raw) = spec.path.as_deref() else {
4961 return Err(McpError::new(
4962 ErrorCode::InvalidArgument,
4963 format!("temp_attach alias '{}' requires a 'path'", spec.alias),
4964 ));
4965 };
4966 let resolved = match on_missing {
4967 attach::OnMissing::Error => attach::validate_local_path(raw)?,
4968 attach::OnMissing::Create => attach::validate_local_path_for_create(raw)?,
4969 };
4970 AttachSource::LocalFile { path: resolved }
4971 }
4972 other => {
4973 return Err(McpError::new(
4974 ErrorCode::InvalidArgument,
4975 format!(
4976 "Unsupported temp_attach kind '{other}' for alias '{}'. Only 'local_file' is supported today.",
4977 spec.alias
4978 ),
4979 ));
4980 }
4981 };
4982 attach::validate_alias(&spec.alias)?;
4983 out.push(AttachRequest {
4984 alias: spec.alias.clone(),
4985 source,
4986 writable,
4987 on_missing,
4988 });
4989 }
4990 Ok(out)
4991}
4992
4993fn perform_copy(
4998 engine: &Engine,
4999 mode: &str,
5000 target_db: Option<&str>,
5001 target_table: &str,
5002 sql_body: &str,
5003) -> Result<Value, McpError> {
5004 let qualified = qualified_name(engine, target_db, target_table);
5005 let exists = target_exists(engine, target_db, target_table)?;
5006 let timer = crate::stats::StatsTimer::start();
5007
5008 match mode {
5009 "create" => {
5010 if exists {
5011 return Err(McpError::new(
5012 ErrorCode::InvalidArgument,
5013 format!(
5014 "Target '{target_table}' already exists. Use mode='append' to add rows or mode='replace' to drop and recreate."
5015 ),
5016 ));
5017 }
5018 engine.execute_command(&format!("CREATE TABLE {qualified} AS {sql_body}"))?;
5019 }
5020 "append" => {
5021 if !exists {
5022 return Err(McpError::new(
5023 ErrorCode::InvalidArgument,
5024 format!(
5025 "Target '{target_table}' does not exist. Use mode='create' to create it from the query or mode='replace' to drop and recreate."
5026 ),
5027 ));
5028 }
5029 engine.execute_command(&format!("INSERT INTO {qualified} {sql_body}"))?;
5030 }
5031 "replace" => {
5032 engine.execute_command(&format!("DROP TABLE IF EXISTS {qualified}"))?;
5041 engine.execute_command(&format!("CREATE TABLE {qualified} AS {sql_body}"))?;
5042 }
5043 other => {
5044 return Err(McpError::new(
5045 ErrorCode::InvalidArgument,
5046 format!("copy_query mode '{other}' is not supported"),
5047 ));
5048 }
5049 }
5050
5051 let elapsed_ms = timer.elapsed_ms();
5052 let row_count = count_rows(engine, target_db, target_table);
5053 Ok(json!({
5054 "target_table": target_table,
5055 "target_database": target_db.unwrap_or(LOCAL_ALIAS),
5056 "mode": mode,
5057 "row_count": row_count,
5058 "stats": { "operation": "copy_query", "elapsed_ms": elapsed_ms },
5059 }))
5060}
5061
5062#[cfg(test)]
5063mod validate_execute_batch_tests {
5064 use super::*;
5065
5066 fn s(v: &[&str]) -> Vec<String> {
5067 v.iter().map(|x| (*x).to_string()).collect()
5068 }
5069
5070 #[test]
5071 fn rejects_empty_array() {
5072 let err = validate_execute_batch(&[]).unwrap_err();
5073 assert_eq!(err.code, ErrorCode::InvalidArgument);
5074 }
5075
5076 #[test]
5077 fn rejects_whitespace_only_element() {
5078 let err = validate_execute_batch(&s(&[" "])).unwrap_err();
5079 assert_eq!(err.code, ErrorCode::InvalidArgument);
5080 assert!(err.message.contains("sql[0]"));
5081 let err = validate_execute_batch(&s(&["INSERT INTO t VALUES (1)", "/* */"])).unwrap_err();
5082 assert!(err.message.contains("sql[1]"));
5083 }
5084
5085 #[test]
5086 fn rejects_read_only_element() {
5087 let err =
5088 validate_execute_batch(&s(&["INSERT INTO t VALUES (1)", "SELECT 1"])).unwrap_err();
5089 assert_eq!(err.code, ErrorCode::SqlError);
5090 assert!(err.message.contains("sql[1]"));
5091 }
5092
5093 #[test]
5094 fn rejects_transaction_control_in_batch() {
5095 for sql in [
5098 "BEGIN",
5099 "COMMIT",
5100 "ROLLBACK",
5101 "SAVEPOINT sp1",
5102 "START TRANSACTION",
5103 "END",
5104 "RELEASE SAVEPOINT sp1",
5105 ] {
5106 let err = validate_execute_batch(&s(&["INSERT INTO t VALUES (1)", sql])).unwrap_err();
5107 assert_eq!(err.code, ErrorCode::InvalidArgument, "for `{sql}`");
5108 assert!(
5109 err.message.contains("transaction-control"),
5110 "for `{sql}`: {}",
5111 err.message
5112 );
5113 }
5114 }
5115
5116 #[test]
5117 fn rejects_transaction_control_singleton() {
5118 let err = validate_execute_batch(&s(&["BEGIN"])).unwrap_err();
5123 assert_eq!(err.code, ErrorCode::InvalidArgument);
5124 }
5125
5126 #[test]
5127 fn rejects_ddl_dml_mix() {
5128 let err =
5129 validate_execute_batch(&s(&["CREATE TABLE x (i INT)", "INSERT INTO x VALUES (1)"]))
5130 .unwrap_err();
5131 assert_eq!(err.code, ErrorCode::InvalidArgument);
5132 assert!(err.message.contains("DDL"));
5133 }
5134
5135 #[test]
5136 fn rejects_multi_ddl() {
5137 let err = validate_execute_batch(&s(&["CREATE TABLE a (i INT)", "CREATE TABLE b (j INT)"]))
5138 .unwrap_err();
5139 assert_eq!(err.code, ErrorCode::InvalidArgument);
5140 assert!(err.message.contains("Multi-statement DDL"));
5141 }
5142
5143 #[test]
5144 fn allows_single_ddl() {
5145 validate_execute_batch(&s(&["CREATE TABLE a (i INT)"])).unwrap();
5146 }
5147
5148 #[test]
5149 fn allows_multi_dml() {
5150 validate_execute_batch(&s(&[
5151 "UPDATE settings SET value = 'x' WHERE key = 'k'",
5152 "INSERT INTO settings (key, value) SELECT 'k', 'x' WHERE NOT EXISTS (SELECT 1 FROM settings WHERE key = 'k')",
5153 ]))
5154 .unwrap();
5155 }
5156
5157 #[test]
5158 fn allows_other_kinds() {
5159 validate_execute_batch(&s(&["SET schema_search_path = 'mydb'"])).unwrap();
5163 }
5164
5165 #[test]
5166 fn allows_trailing_semicolon() {
5167 validate_execute_batch(&s(&["INSERT INTO t VALUES (1);"])).unwrap();
5168 }
5169}