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: Option<String>,
853 pub value_path: Option<String>,
857 pub overwrite: Option<bool>,
861 pub database: Option<String>,
866 pub persist: Option<bool>,
870}
871
872#[derive(Debug, Deserialize, JsonSchema)]
874pub struct KvEntry {
875 pub key: String,
877 pub value: String,
879}
880
881#[derive(Debug, Deserialize, JsonSchema)]
883pub struct KvSetManyParams {
884 pub store: String,
886 pub entries: Vec<KvEntry>,
890 pub overwrite: Option<bool>,
894 pub database: Option<String>,
899 pub persist: Option<bool>,
903}
904
905#[derive(Debug, Deserialize, JsonSchema)]
907pub struct KvStoreParams {
908 pub store: String,
910 pub database: Option<String>,
914 pub persist: Option<bool>,
917}
918
919#[derive(Debug, Deserialize, JsonSchema)]
921pub struct KvListParams {
922 pub store: String,
924 pub values: Option<bool>,
928 pub database: Option<String>,
932 pub persist: Option<bool>,
935}
936
937#[derive(Debug, Deserialize, JsonSchema)]
939pub struct KvListStoresParams {
940 pub database: Option<String>,
944 pub persist: Option<bool>,
947}
948
949#[derive(Debug, Deserialize, JsonSchema)]
953pub struct AnalyzeTableArgs {
954 pub table: String,
956}
957
958#[derive(Debug, Deserialize, JsonSchema)]
960pub struct CompareTablesArgs {
961 pub table_a: String,
963 pub table_b: String,
965}
966
967#[derive(Debug, Deserialize, JsonSchema)]
969pub struct DataQualityArgs {
970 pub table: String,
972}
973
974#[derive(Debug, Deserialize, JsonSchema)]
976pub struct SuggestQueriesArgs {
977 pub table: String,
979 pub goal: Option<String>,
981}
982
983pub struct HyperMcpServer {
992 engine: Arc<Mutex<Option<Engine>>>,
993 catalog_ready: Arc<Mutex<bool>>,
999 watchers: Arc<crate::watcher::WatcherRegistry>,
1000 saved_queries: Arc<dyn SavedQueryStore>,
1001 subscriptions: Arc<SubscriptionRegistry>,
1002 attachments: Arc<AttachRegistry>,
1008 workspace_path: Option<String>,
1012 read_only: bool,
1013 no_daemon: bool,
1015 last_heartbeat: std::sync::Mutex<std::time::Instant>,
1017 client_name: std::sync::Mutex<Option<String>>,
1021 #[expect(dead_code, reason = "constructed for rmcp 1.x macro-based dispatch")]
1026 tool_router: ToolRouter<Self>,
1027 #[expect(dead_code, reason = "constructed for rmcp 1.x macro-based dispatch")]
1028 prompt_router: PromptRouter<Self>,
1029}
1030
1031impl std::fmt::Debug for HyperMcpServer {
1032 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1033 f.debug_struct("HyperMcpServer")
1034 .field("persistent_path", &self.workspace_path)
1035 .field("read_only", &self.read_only)
1036 .field("no_daemon", &self.no_daemon)
1037 .finish_non_exhaustive()
1038 }
1039}
1040
1041impl HyperMcpServer {
1042 pub fn new(persistent_path: Option<String>, read_only: bool) -> Self {
1062 Self::with_options(persistent_path, read_only, false)
1063 }
1064
1065 pub fn with_no_daemon(
1067 persistent_path: Option<String>,
1068 read_only: bool,
1069 no_daemon: bool,
1070 ) -> Self {
1071 Self::with_options(persistent_path, read_only, no_daemon)
1072 }
1073
1074 fn with_options(persistent_path: Option<String>, read_only: bool, no_daemon: bool) -> Self {
1075 let saved_queries: Arc<dyn SavedQueryStore> = build_store(persistent_path.as_deref());
1078 Self {
1079 engine: Arc::new(Mutex::new(None)),
1080 catalog_ready: Arc::new(Mutex::new(false)),
1081 watchers: Arc::new(crate::watcher::WatcherRegistry::new()),
1082 saved_queries,
1083 subscriptions: Arc::new(SubscriptionRegistry::new()),
1084 attachments: Arc::new(AttachRegistry::new()),
1089 workspace_path: persistent_path,
1090 read_only,
1091 no_daemon,
1092 last_heartbeat: std::sync::Mutex::new(std::time::Instant::now()),
1093 client_name: std::sync::Mutex::new(None),
1094 tool_router: Self::tool_router(),
1095 prompt_router: Self::prompt_router(),
1096 }
1097 }
1098
1099 #[must_use]
1103 pub fn subscriptions_handle(&self) -> Arc<SubscriptionRegistry> {
1104 Arc::clone(&self.subscriptions)
1105 }
1106
1107 pub(crate) fn notify_table_changed(&self, table: &str) {
1114 for uri in uris_for_table_change(table) {
1115 self.subscriptions.notify_updated(&uri);
1116 }
1117 }
1118
1119 pub(crate) fn notify_workspace_changed(&self) {
1124 for uri in uris_for_workspace_change() {
1125 self.subscriptions.notify_updated(uri);
1126 }
1127 }
1128
1129 pub(crate) fn notify_resource_list_changed(&self) {
1134 self.subscriptions.notify_list_changed();
1135 }
1136
1137 fn client_name(&self) -> Option<String> {
1140 self.client_name.lock().ok().and_then(|g| g.clone())
1141 }
1142
1143 #[must_use]
1146 pub fn engine_handle(&self) -> Arc<Mutex<Option<Engine>>> {
1147 Arc::clone(&self.engine)
1148 }
1149
1150 #[must_use]
1152 pub fn watchers_handle(&self) -> Arc<crate::watcher::WatcherRegistry> {
1153 Arc::clone(&self.watchers)
1154 }
1155
1156 #[must_use]
1159 pub fn attachments_handle(&self) -> Arc<AttachRegistry> {
1160 Arc::clone(&self.attachments)
1161 }
1162
1163 #[must_use]
1165 pub fn is_read_only(&self) -> bool {
1166 self.read_only
1167 }
1168
1169 fn check_writable(&self, operation: &str) -> Result<(), McpError> {
1172 if self.read_only {
1173 Err(McpError::new(
1174 ErrorCode::ReadOnlyViolation,
1175 format!("Operation '{operation}' is not permitted in read-only mode"),
1176 ))
1177 } else {
1178 Ok(())
1179 }
1180 }
1181
1182 fn resolve_db(
1191 &self,
1192 engine: &Engine,
1193 database: Option<&str>,
1194 persist: Option<bool>,
1195 require_writable: bool,
1196 ) -> Result<Option<String>, McpError> {
1197 let effective = match (database, persist) {
1198 (Some(db), _) => Some(db),
1199 (None, Some(true)) => Some(Engine::PERSISTENT_ALIAS),
1200 _ => None,
1201 };
1202 let effective = effective.filter(|s| !s.eq_ignore_ascii_case(crate::attach::LOCAL_ALIAS));
1204
1205 let resolved = engine.resolve_target_db(effective)?;
1206 let primary = engine.primary_db_name();
1207
1208 if resolved == primary {
1209 return Ok(None);
1210 }
1211
1212 if require_writable && resolved != Engine::PERSISTENT_ALIAS {
1213 match self.attachments.get(&resolved) {
1214 None => {
1215 return Err(McpError::new(
1216 ErrorCode::InvalidArgument,
1217 format!(
1218 "database '{resolved}' is not attached. \
1219 Call attach_database first, or use \"persistent\"."
1220 ),
1221 ));
1222 }
1223 Some(entry) if !entry.writable => {
1224 return Err(McpError::new(
1225 ErrorCode::InvalidArgument,
1226 format!(
1227 "database '{resolved}' was attached read-only. \
1228 Re-attach with writable:true to write to it."
1229 ),
1230 ));
1231 }
1232 _ => {}
1233 }
1234 }
1235
1236 Ok(Some(resolved))
1237 }
1238
1239 const KV_SOFT_SIZE_WARN_BYTES: usize = 1_048_576;
1242
1243 fn kv_size_warning(bytes: usize) -> Option<String> {
1247 (bytes > Self::KV_SOFT_SIZE_WARN_BYTES).then(|| {
1248 format!(
1249 "value is {bytes} bytes (> {} soft limit); the KV \
1250 store is for small scraps — consider load_data or a real table for large payloads",
1251 Self::KV_SOFT_SIZE_WARN_BYTES
1252 )
1253 })
1254 }
1255
1256 const KV_VALUE_PATH_MAX_BYTES: u64 = 64 * 1024 * 1024;
1262
1263 fn check_value_path_size(bytes: u64) -> Result<(), McpError> {
1268 if bytes > Self::KV_VALUE_PATH_MAX_BYTES {
1269 return Err(McpError::new(
1270 ErrorCode::InvalidArgument,
1271 format!(
1272 "value_path file is {bytes} bytes (> {} hard limit); the KV store \
1273 is for small scraps — use load_file or a real table for large payloads",
1274 Self::KV_VALUE_PATH_MAX_BYTES
1275 ),
1276 ));
1277 }
1278 Ok(())
1279 }
1280
1281 fn kv_open<'e>(
1290 engine: &'e Engine,
1291 database: Option<&str>,
1292 store: &str,
1293 ) -> Result<hyperdb_api::KvStore<'e>, McpError> {
1294 match database {
1295 Some(alias) => engine.connection().kv_store_in(alias, store),
1296 None => engine.connection().kv_store(store),
1297 }
1298 .map_err(McpError::from)
1299 }
1300
1301 fn ensure_engine(&self) -> Result<std::sync::MutexGuard<'_, Option<Engine>>, McpError> {
1310 let mut guard = self
1311 .engine
1312 .lock()
1313 .map_err(|_| McpError::new(ErrorCode::InternalError, "Lock poisoned"))?;
1314 if guard.is_none() {
1315 tracing::info!(
1316 persistent_db = self.workspace_path.as_deref().unwrap_or("<ephemeral-only>"),
1317 no_daemon = self.no_daemon,
1318 "initializing hyper engine"
1319 );
1320 let engine = if self.no_daemon {
1321 Engine::new_no_daemon(self.workspace_path.clone())?
1322 } else {
1323 Engine::new(self.workspace_path.clone())?
1324 };
1325 tracing::info!(
1326 ephemeral_path = %engine.ephemeral_path().display(),
1327 persistent_path = ?engine.persistent_path(),
1328 log_dir = %engine.log_dir().display(),
1329 "engine ready"
1330 );
1331 if let Err(e) = self.attachments.replay_all(&engine) {
1340 tracing::warn!(err = %e.message, "failed to replay attachments on new engine");
1341 }
1342 *guard = Some(engine);
1343 if let Ok(mut ready) = self.catalog_ready.lock() {
1347 *ready = false;
1348 }
1349 }
1350 Ok(guard)
1351 }
1352
1353 pub fn warm_up_engine(&self) {
1363 match self.ensure_engine() {
1364 Ok(_guard) => {
1365 tracing::info!("engine initialized eagerly at startup");
1366 }
1367 Err(e) => {
1368 tracing::warn!(
1369 err = %e.message,
1370 "eager engine initialization failed at startup; \
1371 will retry on first data-plane tool call"
1372 );
1373 }
1374 }
1375 }
1376
1377 fn ensure_catalog_ready(&self, engine: &Engine) {
1386 if self.read_only {
1387 return;
1388 }
1389 let Ok(mut ready) = self.catalog_ready.lock() else {
1390 return;
1391 };
1392 if *ready {
1393 return;
1394 }
1395 if let Err(e) = crate::table_catalog::ensure_exists(engine) {
1396 tracing::warn!(err = %e.message, "failed to ensure _table_catalog exists");
1397 }
1398 if let Err(e) = crate::table_catalog::reconcile(engine) {
1399 tracing::warn!(err = %e.message, "failed to reconcile _table_catalog on startup");
1400 }
1401 *ready = true;
1402 }
1403
1404 fn after_ingest_catalog_update(
1417 &self,
1418 engine: &Engine,
1419 table_name: &str,
1420 load_tool: &'static str,
1421 load_params: Option<&str>,
1422 row_count: Option<i64>,
1423 target_db: Option<&str>,
1424 ) {
1425 if let Err(e) = crate::table_catalog::upsert_stub_in(
1426 engine,
1427 table_name,
1428 load_tool,
1429 load_params,
1430 row_count,
1431 true,
1432 target_db,
1433 self.client_name().as_deref(),
1434 ) {
1435 tracing::warn!(
1436 table = %table_name,
1437 target_db = ?target_db,
1438 err = %e.message,
1439 "failed to update _table_catalog after ingest"
1440 );
1441 }
1442 }
1443
1444 #[expect(
1454 clippy::unused_self,
1455 reason = "&self required for method-call dispatch; body uses only engine + target_db"
1456 )]
1457 fn after_execute_catalog_update(&self, engine: &Engine, target_db: Option<&str>) {
1458 if let Err(e) = crate::table_catalog::reconcile_in(engine, None) {
1459 tracing::warn!(
1460 err = %e.message,
1461 "failed to reconcile persistent _table_catalog after execute"
1462 );
1463 }
1464 if let Some(alias) = target_db {
1465 if !alias.eq_ignore_ascii_case(Engine::PERSISTENT_ALIAS) {
1466 if let Err(e) = crate::table_catalog::reconcile_in(engine, Some(alias)) {
1467 tracing::warn!(
1468 target_db = alias,
1469 err = %e.message,
1470 "failed to reconcile user-DB _table_catalog after execute"
1471 );
1472 }
1473 }
1474 }
1475 }
1476
1477 fn with_engine<F, R>(&self, f: F) -> Result<R, McpError>
1486 where
1487 F: FnOnce(&Engine) -> Result<R, McpError>,
1488 {
1489 let mut guard = self.ensure_engine()?;
1490 let engine = guard.as_ref().expect("ensure_engine guarantees Some");
1491 self.ensure_catalog_ready(engine);
1496 if !self.no_daemon {
1501 self.maybe_send_heartbeat(engine.daemon_health_port());
1502 }
1503 let result = f(engine);
1504 if let Err(e) = &result {
1505 tracing::debug!(code = ?e.code, message = %e.message, "tool call returned error");
1506 if e.code == ErrorCode::ConnectionLost {
1507 tracing::warn!(
1508 "connection to hyperd lost or desynchronized ({}); \
1513 dropping engine so next call reconnects",
1514 e.message
1515 );
1516 *guard = None;
1517 if let Ok(mut ready) = self.catalog_ready.lock() {
1520 *ready = false;
1521 }
1522 if !self.no_daemon {
1526 crate::daemon::health::report_hyperd_error_to_daemon();
1527 }
1528 }
1529 }
1530 result
1531 }
1532
1533 fn maybe_send_heartbeat(&self, daemon_health_port: Option<u16>) {
1541 const HEARTBEAT_INTERVAL: std::time::Duration = std::time::Duration::from_secs(60);
1542 let should_send = self
1543 .last_heartbeat
1544 .lock()
1545 .is_ok_and(|guard| guard.elapsed() >= HEARTBEAT_INTERVAL);
1546 if should_send {
1547 if let Some(port) = daemon_health_port {
1548 let _ = crate::daemon::health::send_command(port, "HEARTBEAT");
1549 if let Ok(mut guard) = self.last_heartbeat.lock() {
1550 *guard = std::time::Instant::now();
1551 }
1552 }
1553 }
1554 }
1555
1556 fn status_degraded(&self) -> Value {
1569 let (hyperd_running, engine_block) = if let Some(info) =
1574 crate::daemon::discovery::discover()
1575 {
1576 (
1577 true,
1578 json!({
1579 "mode": "daemon",
1580 "hyperd_endpoint": info.hyperd_endpoint,
1581 "daemon_health_port": info.health_port,
1582 }),
1583 )
1584 } else if self.no_daemon {
1585 (
1587 false,
1588 json!({ "mode": "local", "hyperd_endpoint": null, "daemon_health_port": null }),
1589 )
1590 } else {
1591 (
1592 false,
1593 json!({ "mode": "daemon", "hyperd_endpoint": null, "daemon_health_port": null }),
1594 )
1595 };
1596
1597 let persistent_path = self
1598 .workspace_path
1599 .as_ref()
1600 .map_or(Value::Null, |p| Value::String(p.clone()));
1601
1602 let attachments: Vec<Value> = self
1603 .attachments
1604 .list()
1605 .iter()
1606 .map(super::attach::AttachedDb::to_json)
1607 .collect();
1608
1609 json!({
1610 "engine_busy": true,
1611 "hyperd_running": hyperd_running,
1612 "persistent_path": persistent_path,
1613 "has_persistent": self.workspace_path.is_some(),
1614 "engine": engine_block,
1615 "hyper_rust_api_version": crate::version::mcp_version_string(),
1616 "watchers": self.watchers.to_json(),
1617 "read_only": self.read_only,
1618 "attachments": attachments,
1619 })
1620 }
1621
1622 fn with_saved_query_store<F, R>(&self, f: F) -> Result<R, McpError>
1632 where
1633 F: FnOnce(Option<&Engine>) -> Result<R, McpError>,
1634 {
1635 if self.workspace_path.is_some() {
1636 self.with_engine(|engine| f(Some(engine)))
1637 } else {
1638 f(None)
1639 }
1640 }
1641
1642 #[expect(
1643 clippy::unnecessary_wraps,
1644 reason = "signature retained for API symmetry / future fallibility; returning Result/Option keeps callers from breaking when the function later grows failure cases"
1645 )]
1646 fn ok_content(val: Value) -> Result<CallToolResult, rmcp::ErrorData> {
1651 let text = serde_json::to_string_pretty(&val).unwrap_or_default();
1652 let mut result = CallToolResult::structured(val);
1653 result.content = vec![Content::text(text)];
1657 Ok(result)
1658 }
1659
1660 fn fmt_sql(sql: &str) -> String {
1663 let opts = FormatOptions {
1664 indent: Indent::Spaces(2),
1665 uppercase: Some(true),
1666 lines_between_queries: 1,
1667 ..FormatOptions::default()
1668 };
1669 let formatted = sqlformat::format(sql, &SqlQueryParams::None, &opts);
1670 if formatted.trim().is_empty() {
1671 sql.to_owned()
1672 } else {
1673 formatted
1674 }
1675 }
1676
1677 #[expect(
1678 clippy::unnecessary_wraps,
1679 reason = "signature retained for API symmetry / future fallibility; returning Result/Option keeps callers from breaking when the function later grows failure cases"
1680 )]
1681 #[expect(
1682 clippy::needless_pass_by_value,
1683 reason = "call-site ergonomics: function consumes logically-owned parameters, refactoring signatures is not worth per-site churn"
1684 )]
1685 fn err_content(e: McpError) -> Result<CallToolResult, rmcp::ErrorData> {
1690 let err_val = serde_json::to_value(&e).unwrap_or(Value::String(e.to_string()));
1691 let body = json!({"error": err_val});
1692 let text = serde_json::to_string_pretty(&body).unwrap_or_default();
1693 let mut result = CallToolResult::structured_error(body);
1694 result.content = vec![Content::text(text)];
1695 Ok(result)
1696 }
1697}
1698
1699#[tool_router]
1700impl HyperMcpServer {
1701 #[tool(
1703 description = "Ingest inline data (JSON or CSV) and run a SQL query in one call. Creates a temp table, queries, discards."
1704 )]
1705 fn query_data(
1706 &self,
1707 Parameters(params): Parameters<QueryDataParams>,
1708 ) -> Result<CallToolResult, rmcp::ErrorData> {
1709 let result = self.with_engine(|engine| {
1710 let tname = params.table_name.unwrap_or_else(|| "data".into());
1711 let temp_table = format!("_tmp_{}_{}", tname, rand_suffix());
1712 let fmt = params.format.unwrap_or_else(|| detect_format(¶ms.data));
1713 let schema_override = crate::schema::normalize_schema_param(params.schema.as_ref())?;
1714 let opts = IngestOptions {
1715 table: temp_table.clone(),
1716 mode: "replace".into(),
1717 schema_override,
1718 merge_key: None,
1719 target_db: None,
1720 };
1721
1722 let ingest_result = match fmt.as_str() {
1723 "csv" => ingest_csv(engine, ¶ms.data, &opts),
1724 _ => ingest_json(engine, ¶ms.data, &opts),
1725 }?;
1726
1727 let query_sql = params.sql.replace(&tname, &temp_table);
1728 let rows = engine.execute_query_to_json(&query_sql)?;
1729 let _ = engine.execute_command(&format!("DROP TABLE IF EXISTS \"{temp_table}\""));
1730
1731 Ok(json!({
1732 "sql": Self::fmt_sql(¶ms.sql),
1733 "result": rows,
1734 "stats": ingest_result.stats.to_json(),
1735 }))
1736 });
1737
1738 match result {
1739 Ok(val) => Self::ok_content(val),
1740 Err(e) => Self::err_content(e),
1741 }
1742 }
1743
1744 #[tool(
1746 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."
1747 )]
1748 fn query_file(
1749 &self,
1750 Parameters(params): Parameters<QueryFileParams>,
1751 ) -> Result<CallToolResult, rmcp::ErrorData> {
1752 let result = self.with_engine(|engine| {
1753 crate::attach::validate_input_path(¶ms.path, "data file")?;
1754 let stem = std::path::Path::new(¶ms.path)
1755 .file_stem()
1756 .and_then(|s| s.to_str())
1757 .unwrap_or("file")
1758 .to_string();
1759 let tname = params.table_name.unwrap_or_else(|| stem.clone());
1760 let temp_table = format!("_tmp_{}_{}", tname, rand_suffix());
1761 let schema_override = crate::schema::normalize_schema_param(params.schema.as_ref())?;
1762 let opts = IngestOptions {
1763 table: temp_table.clone(),
1764 mode: "replace".into(),
1765 schema_override,
1766 merge_key: None,
1767 target_db: None,
1768 };
1769
1770 let ingest_result = if let Some(ref json_path) = params.json_extract_path {
1771 let raw = std::fs::read_to_string(¶ms.path)
1772 .map_err(|e| McpError::from_io_error(&e, "load_file"))?;
1773 let extracted = crate::ingest::extract_json_path(&raw, json_path)?;
1774 let array_text = crate::ingest::normalize_json_or_jsonl(&extracted)?;
1775 let mut result = ingest_json(engine, &array_text, &opts)?;
1776 result.stats.operation = "query_file".into();
1777 result.stats.bytes_read = std::fs::metadata(¶ms.path).map_or(0, |m| m.len());
1778 result.stats.file_format = Some("json".into());
1779 result
1780 } else {
1781 match detect_file_format(std::path::Path::new(¶ms.path)) {
1782 InferredFileFormat::Parquet => ingest_parquet_file(engine, ¶ms.path, &opts),
1783 InferredFileFormat::ArrowIpc => {
1784 ingest_arrow_ipc_file(engine, ¶ms.path, &opts)
1785 }
1786 InferredFileFormat::Json => ingest_json_file(engine, ¶ms.path, &opts),
1787 InferredFileFormat::Csv => ingest_csv_file(engine, ¶ms.path, &opts),
1788 }?
1789 };
1790
1791 let query_sql = params.sql.replace(&tname, &temp_table);
1792 let rows = engine.execute_query_to_json(&query_sql)?;
1793 let _ = engine.execute_command(&format!("DROP TABLE IF EXISTS \"{temp_table}\""));
1794
1795 Ok(json!({
1796 "sql": Self::fmt_sql(¶ms.sql),
1797 "result": rows,
1798 "stats": ingest_result.stats.to_json(),
1799 }))
1800 });
1801
1802 match result {
1803 Ok(val) => Self::ok_content(val),
1804 Err(e) => Self::err_content(e),
1805 }
1806 }
1807
1808 #[tool(
1810 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))."
1811 )]
1812 fn load_data(
1813 &self,
1814 Parameters(params): Parameters<LoadDataParams>,
1815 ) -> Result<CallToolResult, rmcp::ErrorData> {
1816 if let Err(e) = self.check_writable("load_data") {
1817 return Self::err_content(e);
1818 }
1819 let table_name = params.table.clone();
1820 let mode = params.mode.clone().unwrap_or_else(|| "replace".into());
1825 let result = self.with_engine(|engine| {
1826 let target_db =
1827 self.resolve_db(engine, params.database.as_deref(), params.persist, true)?;
1828 let fmt = params.format.unwrap_or_else(|| detect_format(¶ms.data));
1829 let schema_override = crate::schema::normalize_schema_param(params.schema.as_ref())?;
1830 let opts = IngestOptions {
1831 table: params.table.clone(),
1832 mode: mode.clone(),
1833 schema_override,
1834 merge_key: None,
1835 target_db: target_db.clone(),
1836 };
1837
1838 let ingest_result = match fmt.as_str() {
1839 "csv" => ingest_csv(engine, ¶ms.data, &opts),
1840 _ => ingest_json(engine, ¶ms.data, &opts),
1841 }?;
1842
1843 let schema_json: Vec<Value> = ingest_result
1844 .schema
1845 .iter()
1846 .map(|c| {
1847 json!({
1848 "name": c.name,
1849 "type": c.hyper_type,
1850 "nullable": c.nullable,
1851 })
1852 })
1853 .collect();
1854
1855 {
1861 let load_params = serde_json::to_string(&json!({
1862 "mode": mode,
1863 "format": fmt,
1864 "database": target_db.as_deref().unwrap_or("local"),
1865 }))
1866 .ok();
1867 self.after_ingest_catalog_update(
1868 engine,
1869 ¶ms.table,
1870 "load_data",
1871 load_params.as_deref(),
1872 i64::try_from(ingest_result.rows).ok(),
1873 target_db.as_deref(),
1874 );
1875 }
1876
1877 Ok(json!({
1878 "rows": ingest_result.rows,
1879 "schema": schema_json,
1880 "stats": ingest_result.stats.to_json(),
1881 }))
1882 });
1883
1884 match result {
1885 Ok(val) => {
1886 self.notify_table_changed(&table_name);
1887 if mode == "replace" {
1888 self.notify_resource_list_changed();
1892 }
1893 Self::ok_content(val)
1894 }
1895 Err(e) => Self::err_content(e),
1896 }
1897 }
1898
1899 #[tool(
1901 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."
1902 )]
1903 fn load_file(
1904 &self,
1905 Parameters(params): Parameters<LoadFileParams>,
1906 ) -> Result<CallToolResult, rmcp::ErrorData> {
1907 if let Err(e) = self.check_writable("load_file") {
1908 return Self::err_content(e);
1909 }
1910 let table_name = params.table.clone();
1911 let mode = params.mode.clone().unwrap_or_else(|| "replace".into());
1912 let merge_key_vec = match validate_merge_args(&mode, params.merge_key) {
1916 Ok(v) => v,
1917 Err(e) => return Self::err_content(e),
1918 };
1919 let result = self.with_engine(|engine| {
1924 let target_db =
1925 self.resolve_db(engine, params.database.as_deref(), params.persist, true)?;
1926 crate::attach::validate_input_path(¶ms.path, "data file")?;
1927 let schema_override = crate::schema::normalize_schema_param(params.schema.as_ref())?;
1928 let opts = IngestOptions {
1929 table: params.table.clone(),
1930 mode: mode.clone(),
1931 schema_override,
1932 merge_key: merge_key_vec.clone(),
1933 target_db: target_db.clone(),
1934 };
1935
1936 let ingest_result = if let Some(ref json_path) = params.json_extract_path {
1937 let raw = std::fs::read_to_string(¶ms.path)
1938 .map_err(|e| McpError::from_io_error(&e, "load_file"))?;
1939 let extracted = crate::ingest::extract_json_path(&raw, json_path)?;
1940 let array_text = crate::ingest::normalize_json_or_jsonl(&extracted)?;
1941 let mut result = ingest_json(engine, &array_text, &opts)?;
1942 result.stats.operation = "load_file".into();
1943 result.stats.bytes_read = std::fs::metadata(¶ms.path).map_or(0, |m| m.len());
1944 result.stats.file_format = Some("json".into());
1945 result
1946 } else {
1947 match detect_file_format(std::path::Path::new(¶ms.path)) {
1948 InferredFileFormat::Parquet => ingest_parquet_file(engine, ¶ms.path, &opts),
1949 InferredFileFormat::ArrowIpc => {
1950 ingest_arrow_ipc_file(engine, ¶ms.path, &opts)
1951 }
1952 InferredFileFormat::Json => ingest_json_file(engine, ¶ms.path, &opts),
1953 InferredFileFormat::Csv => ingest_csv_file(engine, ¶ms.path, &opts),
1954 }?
1955 };
1956
1957 let schema_changed = ingest_result.stats.schema_changed;
1961
1962 let schema_json: Vec<Value> = ingest_result
1963 .schema
1964 .iter()
1965 .map(|c| {
1966 json!({
1967 "name": c.name,
1968 "type": c.hyper_type,
1969 "nullable": c.nullable,
1970 })
1971 })
1972 .collect();
1973
1974 {
1976 let load_params = serde_json::to_string(&json!({
1977 "source_path": params.path,
1978 "mode": mode,
1979 "schema": params.schema,
1980 "json_extract_path": params.json_extract_path,
1981 "merge_key": merge_key_vec,
1982 "database": target_db.as_deref().unwrap_or("local"),
1983 }))
1984 .ok();
1985 self.after_ingest_catalog_update(
1986 engine,
1987 ¶ms.table,
1988 "load_file",
1989 load_params.as_deref(),
1990 i64::try_from(ingest_result.rows).ok(),
1991 target_db.as_deref(),
1992 );
1993 }
1994
1995 Ok((
1996 json!({
1997 "rows": ingest_result.rows,
1998 "schema": schema_json,
1999 "stats": ingest_result.stats.to_json(),
2000 }),
2001 schema_changed,
2002 ))
2003 });
2004
2005 match result {
2006 Ok((val, schema_changed)) => {
2007 self.notify_table_changed(&table_name);
2008 if mode == "replace" || schema_changed {
2016 self.notify_resource_list_changed();
2017 }
2018 Self::ok_content(val)
2019 }
2020 Err(e) => Self::err_content(e),
2021 }
2022 }
2023
2024 #[tool(
2028 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.**"
2029 )]
2030 fn load_files(
2031 &self,
2032 Parameters(params): Parameters<LoadFilesParams>,
2033 ) -> Result<CallToolResult, rmcp::ErrorData> {
2034 use hyperdb_api::pool::{create_pool, PoolConfig};
2035 use hyperdb_api::CreateMode;
2036
2037 if let Err(e) = self.check_writable("load_files") {
2038 return Self::err_content(e);
2039 }
2040 if params.files.is_empty() {
2041 return Self::err_content(McpError::new(
2042 ErrorCode::EmptyData,
2043 "load_files: `files` must not be empty",
2044 ));
2045 }
2046
2047 for (idx, entry) in params.files.iter().enumerate() {
2054 if let Err(mut e) = crate::attach::validate_input_path(&entry.path, "data file") {
2055 e.message = format!("entry {idx} (table '{}'): {}", entry.table, e.message);
2056 return Self::err_content(e);
2057 }
2058 let mode = entry.mode.as_deref().unwrap_or("replace");
2059 if mode == "merge" || entry.merge_key.is_some() {
2060 return Self::err_content(McpError::new(
2061 ErrorCode::InvalidArgument,
2062 format!(
2063 "load_files does not support mode=merge yet (entry {idx}, table \
2064 '{}'). Call load_file once per file when you need merge semantics.",
2065 entry.table
2066 ),
2067 ));
2068 }
2069 }
2070
2071 let (endpoint, workspace, target_db) = match self.with_engine(|engine| {
2077 let target_db =
2078 self.resolve_db(engine, params.database.as_deref(), params.persist, true)?;
2079 let endpoint = engine.hyperd_endpoint()?;
2080 let workspace = match target_db.as_deref() {
2081 None => engine.ephemeral_path().to_string_lossy().to_string(),
2082 Some(alias) if alias.eq_ignore_ascii_case(Engine::PERSISTENT_ALIAS) => engine
2083 .persistent_path()
2084 .ok_or_else(|| {
2085 McpError::new(
2086 ErrorCode::InvalidArgument,
2087 "target 'persistent' but the server is in --ephemeral-only mode",
2088 )
2089 })?
2090 .to_string_lossy()
2091 .to_string(),
2092 Some(alias) => {
2093 let entry = self.attachments.get(alias).ok_or_else(|| {
2094 McpError::new(
2095 ErrorCode::InvalidArgument,
2096 format!("database '{alias}' is not attached"),
2097 )
2098 })?;
2099 let crate::attach::AttachSource::LocalFile { path } = &entry.source;
2100 path.to_string_lossy().to_string()
2101 }
2102 };
2103 Ok((endpoint, workspace, target_db))
2104 }) {
2105 Ok(v) => v,
2106 Err(e) => return Self::err_content(e),
2107 };
2108
2109 let file_count = params.files.len();
2112 let concurrency = params
2113 .concurrency
2114 .map_or(8, |n| n as usize)
2115 .min(file_count)
2116 .clamp(1, 16);
2117
2118 let pool = match create_pool(
2119 PoolConfig::new(endpoint, workspace)
2120 .create_mode(CreateMode::DoNotCreate)
2121 .max_size(concurrency),
2122 ) {
2123 Ok(p) => Arc::new(p),
2124 Err(e) => {
2125 return Self::err_content(McpError::new(
2126 ErrorCode::InternalError,
2127 format!("Failed to build connection pool for load_files: {e}"),
2128 ))
2129 }
2130 };
2131
2132 let Ok(rt) = tokio::runtime::Handle::try_current() else {
2135 return Self::err_content(McpError::new(
2136 ErrorCode::InternalError,
2137 "load_files must run inside a tokio runtime",
2138 ));
2139 };
2140
2141 #[derive(Default)]
2144 struct EntryOutcome {
2145 table: String,
2146 ok: Option<(u64, Vec<Value>, Value)>,
2147 err: Option<(ErrorCode, String)>,
2148 replace_mode: bool,
2149 }
2150
2151 let outcomes: Vec<EntryOutcome> = tokio::task::block_in_place(|| {
2152 rt.block_on(async {
2153 let mut set = tokio::task::JoinSet::new();
2154 for (idx, entry) in params.files.into_iter().enumerate() {
2155 let pool = Arc::clone(&pool);
2156 let entry_target_db = target_db.clone();
2157 set.spawn(async move {
2158 let mode = entry.mode.clone().unwrap_or_else(|| "replace".into());
2159 let replace_mode = mode == "replace";
2160 let mut out = EntryOutcome {
2161 table: entry.table.clone(),
2162 replace_mode,
2163 ..Default::default()
2164 };
2165
2166 let schema_override =
2171 match crate::schema::normalize_schema_param(entry.schema.as_ref()) {
2172 Ok(v) => v,
2173 Err(e) => {
2174 out.err = Some((e.code, e.message));
2175 return (idx, out);
2176 }
2177 };
2178 let _ = &entry_target_db;
2187 let opts = IngestOptions {
2188 table: entry.table.clone(),
2189 mode: mode.clone(),
2190 schema_override,
2191 merge_key: None,
2192 target_db: None,
2193 };
2194
2195 let mut conn = match pool.get().await {
2198 Ok(c) => c,
2199 Err(e) => {
2200 out.err = Some((
2201 ErrorCode::InternalError,
2202 format!("Failed to check out connection: {e}"),
2203 ));
2204 return (idx, out);
2205 }
2206 };
2207
2208 let ingest_res = if let Some(ref json_path) = entry.json_extract_path {
2213 let raw = match std::fs::read_to_string(&entry.path) {
2214 Ok(s) => s,
2215 Err(e) => {
2216 out.err = Some((
2217 ErrorCode::FileNotFound,
2218 format!("Cannot read file '{}': {e}", entry.path),
2219 ));
2220 return (idx, out);
2221 }
2222 };
2223 let extracted = match crate::ingest::extract_json_path(&raw, json_path)
2224 {
2225 Ok(v) => v,
2226 Err(e) => {
2227 out.err = Some((e.code, e.message));
2228 return (idx, out);
2229 }
2230 };
2231 let array_text =
2232 match crate::ingest::normalize_json_or_jsonl(&extracted) {
2233 Ok(v) => v,
2234 Err(e) => {
2235 out.err = Some((e.code, e.message));
2236 return (idx, out);
2237 }
2238 };
2239 crate::ingest::ingest_json_async(&mut conn, &array_text, &opts)
2240 .await
2241 .map(|mut r| {
2242 r.stats.operation = "load_file".into();
2243 r.stats.bytes_read =
2244 std::fs::metadata(&entry.path).map_or(0, |m| m.len());
2245 r.stats.file_format = Some("json".into());
2246 r
2247 })
2248 } else {
2249 match detect_file_format(std::path::Path::new(&entry.path)) {
2250 InferredFileFormat::Parquet => {
2251 ingest_parquet_file_async(&mut conn, &entry.path, &opts).await
2252 }
2253 InferredFileFormat::ArrowIpc => {
2254 ingest_arrow_ipc_file_async(&mut conn, &entry.path, &opts).await
2255 }
2256 InferredFileFormat::Json => {
2257 ingest_json_file_async(&mut conn, &entry.path, &opts).await
2258 }
2259 InferredFileFormat::Csv => {
2260 ingest_csv_file_async(&mut conn, &entry.path, &opts).await
2261 }
2262 }
2263 };
2264
2265 match ingest_res {
2266 Ok(r) => {
2267 let schema_json: Vec<Value> = r
2268 .schema
2269 .iter()
2270 .map(|c| {
2271 json!({
2272 "name": c.name,
2273 "type": c.hyper_type,
2274 "nullable": c.nullable,
2275 })
2276 })
2277 .collect();
2278 out.ok = Some((r.rows, schema_json, r.stats.to_json()));
2279 }
2280 Err(e) => {
2281 out.err = Some((e.code, e.message));
2282 }
2283 }
2284
2285 (idx, out)
2286 });
2287 }
2288
2289 let mut collected: Vec<Option<EntryOutcome>> =
2292 (0..file_count).map(|_| None).collect();
2293 while let Some(joined) = set.join_next().await {
2294 match joined {
2295 Ok((idx, outcome)) => collected[idx] = Some(outcome),
2296 Err(e) => {
2297 tracing::warn!("load_files task join error: {e}");
2300 }
2301 }
2302 }
2303 collected.into_iter().flatten().collect()
2304 })
2305 });
2306
2307 let mut any_replace_succeeded = false;
2311 let mut tables_to_notify: Vec<String> = Vec::new();
2312 let results_json: Vec<Value> = outcomes
2313 .iter()
2314 .map(|o| match (&o.ok, &o.err) {
2315 (Some((rows, schema, stats)), _) => {
2316 tables_to_notify.push(o.table.clone());
2317 if o.replace_mode {
2318 any_replace_succeeded = true;
2319 }
2320 json!({
2321 "table": o.table,
2322 "rows": rows,
2323 "schema": schema,
2324 "stats": stats,
2325 })
2326 }
2327 (None, Some((code, msg))) => json!({
2328 "table": o.table,
2329 "error": {
2330 "code": format!("{:?}", code),
2331 "message": msg,
2332 }
2333 }),
2334 (None, None) => json!({
2337 "table": o.table,
2338 "error": {
2339 "code": "InternalError",
2340 "message": "load_files task produced no outcome",
2341 }
2342 }),
2343 })
2344 .collect();
2345
2346 if let Err(e) = self.with_engine(|engine| {
2350 for o in &outcomes {
2351 if let Some((rows, _, _)) = &o.ok {
2352 self.after_ingest_catalog_update(
2353 engine,
2354 &o.table,
2355 "load_file",
2356 None,
2357 i64::try_from(*rows).ok(),
2358 target_db.as_deref(),
2359 );
2360 }
2361 }
2362 Ok(())
2363 }) {
2364 tracing::warn!("load_files: catalog update batch failed: {}", e.message);
2365 }
2366
2367 for t in &tables_to_notify {
2368 self.notify_table_changed(t);
2369 }
2370 if any_replace_succeeded {
2371 self.notify_resource_list_changed();
2372 }
2373
2374 let success_count = outcomes.iter().filter(|o| o.ok.is_some()).count();
2375 let failure_count = outcomes.len() - success_count;
2376
2377 Self::ok_content(json!({
2378 "results": results_json,
2379 "summary": {
2380 "total": outcomes.len(),
2381 "succeeded": success_count,
2382 "failed": failure_count,
2383 "concurrency": concurrency,
2384 }
2385 }))
2386 }
2387
2388 #[tool(
2391 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."
2392 )]
2393 fn load_iceberg(
2394 &self,
2395 Parameters(params): Parameters<LoadIcebergParams>,
2396 ) -> Result<CallToolResult, rmcp::ErrorData> {
2397 if let Err(e) = self.check_writable("load_iceberg") {
2398 return Self::err_content(e);
2399 }
2400 let table_name = params.table.clone();
2401 let mode = params.mode.clone().unwrap_or_else(|| "replace".into());
2402 let opts = crate::lakehouse::IcebergIngestOptions {
2403 table: params.table.clone(),
2404 mode: mode.clone(),
2405 metadata_filename: params.metadata_filename.clone(),
2406 version_as_of: params.version_as_of,
2407 };
2408
2409 let result = self.with_engine(|engine| {
2410 crate::attach::validate_input_path(¶ms.path, "iceberg table")?;
2412 let ingest_result =
2413 crate::lakehouse::ingest_iceberg_table(engine, ¶ms.path, &opts)?;
2414
2415 let schema_json: Vec<Value> = ingest_result
2416 .schema
2417 .iter()
2418 .map(|c| {
2419 json!({
2420 "name": c.name,
2421 "type": c.hyper_type,
2422 "nullable": c.nullable,
2423 })
2424 })
2425 .collect();
2426
2427 let load_params = serde_json::to_string(&json!({
2428 "source_path": params.path,
2429 "mode": mode,
2430 "format": "iceberg",
2431 "metadata_filename": params.metadata_filename,
2432 "version_as_of": params.version_as_of,
2433 }))
2434 .ok();
2435 self.after_ingest_catalog_update(
2436 engine,
2437 ¶ms.table,
2438 "load_iceberg",
2439 load_params.as_deref(),
2440 i64::try_from(ingest_result.rows).ok(),
2441 None,
2442 );
2443
2444 Ok(json!({
2445 "rows": ingest_result.rows,
2446 "schema": schema_json,
2447 "stats": ingest_result.stats.to_json(),
2448 }))
2449 });
2450
2451 match result {
2452 Ok(val) => {
2453 self.notify_table_changed(&table_name);
2454 if mode == "replace" {
2455 self.notify_resource_list_changed();
2456 }
2457 Self::ok_content(val)
2458 }
2459 Err(e) => Self::err_content(e),
2460 }
2461 }
2462
2463 #[tool(
2465 description = "Run a read-only SQL query (SELECT, WITH, EXPLAIN, SHOW, VALUES) against the workspace. For DDL/DML use the execute tool."
2466 )]
2467 fn query(
2468 &self,
2469 Parameters(params): Parameters<QueryParams>,
2470 ) -> Result<CallToolResult, rmcp::ErrorData> {
2471 let result = self.with_engine(|engine| {
2472 if !is_read_only_sql(¶ms.sql) {
2473 return Err(McpError::new(
2474 ErrorCode::SqlError,
2475 "The query tool only accepts read-only SQL (SELECT, WITH, EXPLAIN, SHOW, VALUES). Use the execute tool for DDL/DML.",
2476 ));
2477 }
2478 let target_db = self.resolve_db(engine, params.database.as_deref(), None, false)?;
2481 let _search_guard = match target_db {
2482 Some(ref alias) => Some(engine.scoped_search_path(alias)?),
2483 None => None,
2484 };
2485 const MAX_QUERY_ROWS: usize = 10_000;
2489
2490 let timer = crate::stats::StatsTimer::start();
2491 let mut rows = engine.execute_query_to_json(¶ms.sql)?;
2492 let total_rows = rows.len();
2493 let truncated = total_rows > MAX_QUERY_ROWS;
2494 if truncated {
2495 rows.truncate(MAX_QUERY_ROWS);
2496 }
2497 let elapsed = timer.elapsed_ms();
2498 let stats = crate::stats::QueryStats {
2499 operation: "query".into(),
2500 rows_returned: rows.len() as u64,
2501 rows_scanned: 0,
2502 elapsed_ms: elapsed,
2503 result_size_bytes: serde_json::to_string(&rows).map_or(0, |s| s.len() as u64),
2504 tables_touched: vec![],
2505 };
2506 let payload = if truncated {
2507 json!({
2508 "result": rows,
2509 "stats": stats.to_json(),
2510 "truncated": true,
2511 "total_rows": total_rows,
2512 "rows_returned": MAX_QUERY_ROWS,
2513 "hint": format!(
2514 "Result set has {total_rows} rows; only the first {MAX_QUERY_ROWS} \
2515 are shown. Add a LIMIT clause, aggregate with GROUP BY, or use \
2516 the `export` tool to write the full result to a file."
2517 ),
2518 })
2519 } else {
2520 json!({
2521 "result": rows,
2522 "stats": stats.to_json(),
2523 })
2524 };
2525 Ok((params.sql.clone(), payload))
2526 });
2527
2528 match result {
2529 Ok((sql, val)) => {
2530 let formatted_sql = Self::fmt_sql(&sql);
2531 let json_text = serde_json::to_string_pretty(&val).unwrap_or_default();
2532 Ok(CallToolResult::success(vec![
2533 Content::text(format!("```sql\n{formatted_sql}\n```")),
2534 Content::text(json_text),
2535 ]))
2536 }
2537 Err(e) => Self::err_content(e),
2538 }
2539 }
2540
2541 #[tool(
2543 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."
2544 )]
2545 fn execute(
2546 &self,
2547 Parameters(params): Parameters<ExecuteParams>,
2548 ) -> Result<CallToolResult, rmcp::ErrorData> {
2549 if let Err(e) = self.check_writable("execute") {
2550 return Self::err_content(e);
2551 }
2552 if let Err(e) = validate_execute_batch(¶ms.sql) {
2557 return Self::err_content(e);
2558 }
2559 let any_structural = params
2560 .sql
2561 .iter()
2562 .any(|s| matches!(classify_statement(s), StatementKind::Ddl));
2563 let result = self.with_engine(|engine| {
2564 let target_db = self.resolve_db(engine, params.database.as_deref(), None, true)?;
2568 let _search_guard = match target_db {
2569 Some(ref alias) => Some(engine.scoped_search_path(alias)?),
2570 None => None,
2571 };
2572 let total_timer = crate::stats::StatsTimer::start();
2573 let (per_statement, affected_total, operation): (Vec<Value>, u64, &'static str) =
2574 if params.sql.len() == 1 {
2575 let stmt = ¶ms.sql[0];
2579 let t = crate::stats::StatsTimer::start();
2580 let affected = engine.execute_command(stmt)?;
2581 (
2582 vec![json!({
2583 "sql": Self::fmt_sql(stmt),
2584 "affected_rows": affected,
2585 "elapsed_ms": t.elapsed_ms(),
2586 })],
2587 affected,
2588 "command",
2589 )
2590 } else {
2591 let stmts = ¶ms.sql;
2592 let (results, total) = engine.execute_in_transaction(|engine| {
2593 let mut out = Vec::with_capacity(stmts.len());
2594 let mut total: u64 = 0;
2595 for (idx, stmt) in stmts.iter().enumerate() {
2596 let t = crate::stats::StatsTimer::start();
2597 let affected = engine.execute_command(stmt).map_err(|e| {
2598 let rollback_note = format!(
2603 "Failing SQL: {sql}. All previous statements in this batch were rolled back.",
2604 sql = Self::fmt_sql(stmt)
2605 );
2606 let combined = match e.suggestion.as_deref() {
2607 Some(orig) => format!("{orig} | {rollback_note}"),
2608 None => rollback_note,
2609 };
2610 McpError::new(
2611 e.code,
2612 format!(
2613 "statement {} of {} failed: {}",
2614 idx + 1,
2615 stmts.len(),
2616 e.message
2617 ),
2618 )
2619 .with_suggestion(combined)
2620 })?;
2621 total = total.saturating_add(affected);
2625 out.push(json!({
2626 "sql": Self::fmt_sql(stmt),
2627 "affected_rows": affected,
2628 "elapsed_ms": t.elapsed_ms(),
2629 }));
2630 }
2631 Ok((out, total))
2632 })?;
2633 (results, total, "transaction")
2634 };
2635 let elapsed = total_timer.elapsed_ms();
2636 if any_structural {
2649 self.after_execute_catalog_update(engine, target_db.as_deref());
2650 }
2651 Ok(json!({
2652 "statements": per_statement.len(),
2653 "affected_rows": affected_total,
2654 "per_statement": per_statement,
2655 "stats": { "operation": operation, "elapsed_ms": elapsed },
2656 }))
2657 });
2658
2659 match result {
2660 Ok(val) => {
2661 self.notify_workspace_changed();
2666 if any_structural {
2667 self.notify_resource_list_changed();
2668 }
2669 Self::ok_content(val)
2670 }
2671 Err(e) => Self::err_content(e),
2672 }
2673 }
2674
2675 #[tool(
2677 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."
2678 )]
2679 fn sample(
2680 &self,
2681 Parameters(params): Parameters<SampleParams>,
2682 ) -> Result<CallToolResult, rmcp::ErrorData> {
2683 let result = self.with_engine(|engine| {
2684 let target_db = self.resolve_db(engine, params.database.as_deref(), None, false)?;
2685 let timer = crate::stats::StatsTimer::start();
2686 let n = params.n.unwrap_or(5);
2687 let mut sample = engine.sample_table_in(target_db.as_deref(), ¶ms.table, n)?;
2688 let elapsed = timer.elapsed_ms();
2689 if let Some(obj) = sample.as_object_mut() {
2690 obj.insert(
2691 "stats".into(),
2692 json!({ "operation": "sample", "elapsed_ms": elapsed }),
2693 );
2694 }
2695 Ok(sample)
2696 });
2697
2698 match result {
2699 Ok(val) => Self::ok_content(val),
2700 Err(e) => Self::err_content(e),
2701 }
2702 }
2703
2704 #[tool(
2706 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."
2707 )]
2708 fn chart(
2709 &self,
2710 Parameters(params): Parameters<ChartParams>,
2711 ) -> Result<CallToolResult, rmcp::ErrorData> {
2712 let result = self.with_engine(|engine| {
2713 if !is_read_only_sql(¶ms.sql) {
2714 return Err(McpError::new(
2715 ErrorCode::SqlError,
2716 "The chart tool only accepts read-only SQL (SELECT, WITH, EXPLAIN, SHOW, VALUES).",
2717 ));
2718 }
2719
2720 if let Some(out) = params.output_path.as_deref() {
2723 crate::attach::validate_output_path(out, "chart output")?;
2724 }
2725 let format = crate::chart::resolve_chart_format(
2728 params.format.as_deref(),
2729 params.output_path.as_deref(),
2730 )?;
2731
2732 let target_db = self.resolve_db(engine, params.database.as_deref(), None, false)?;
2735 let _search_guard = match target_db {
2736 Some(ref alias) => Some(engine.scoped_search_path(alias)?),
2737 None => None,
2738 };
2739
2740 let timer = crate::stats::StatsTimer::start();
2741 let rows = engine.execute_query_to_json(¶ms.sql)?;
2742
2743 let color_map = params
2746 .color_map
2747 .as_ref()
2748 .map(|m| {
2749 m.iter()
2750 .filter_map(|(k, v)| {
2751 crate::chart::parse_hex_color(v)
2752 .map(|c| (k.clone(), c))
2753 })
2754 .collect::<std::collections::HashMap<_, _>>()
2755 })
2756 .unwrap_or_default();
2757
2758 let opts = ChartOptions {
2759 chart_type: ChartType::parse(¶ms.chart_type)?,
2760 x_column: params.x.clone(),
2761 y_column: params.y.clone(),
2762 series_column: params.series.clone(),
2763 title: params.title.clone(),
2764 format,
2765 width: params.width.unwrap_or(800).clamp(200, 4096),
2766 height: params.height.unwrap_or(480).clamp(150, 4096),
2767 bins: params.bins.unwrap_or(20).clamp(1, 500),
2768 x_as_category: params.x_as_category,
2769 x_range: params.x_range,
2770 y_range: params.y_range,
2771 color_map,
2772 label_points: params.label_points.unwrap_or(false),
2773 };
2774
2775 let chart = render_chart(&rows, &opts)?;
2776
2777 let disposition = crate::chart::resolve_chart_disposition(
2781 params.inline.unwrap_or(true),
2782 params.output_path.as_deref(),
2783 opts.format,
2784 );
2785 let overwrite = params.overwrite.unwrap_or(true);
2786 if let Some(path) = disposition.path() {
2787 crate::chart::write_chart_to_disk(path, &chart.bytes, overwrite)?;
2788 }
2789
2790 let elapsed = timer.elapsed_ms();
2791 Ok((chart, elapsed, opts, disposition))
2792 });
2793
2794 match result {
2795 Ok((chart, elapsed_ms, opts, disposition)) => {
2796 let format_str = match opts.format {
2797 ChartFormat::Png => "png",
2798 ChartFormat::Svg => "svg",
2799 };
2800 let wants_inline = disposition.wants_inline();
2801 let output_path_str = disposition.path().map(|p| p.to_string_lossy().into_owned());
2802
2803 let mut stats = serde_json::Map::new();
2804 stats.insert("operation".into(), json!("chart"));
2805 stats.insert("rows_plotted".into(), json!(chart.rows_plotted));
2806 stats.insert("elapsed_ms".into(), json!(elapsed_ms));
2807 stats.insert("format".into(), json!(format_str));
2808 stats.insert("bytes".into(), json!(chart.bytes.len()));
2809 stats.insert("width".into(), json!(opts.width));
2810 stats.insert("height".into(), json!(opts.height));
2811 stats.insert("inline".into(), json!(wants_inline));
2812 if let Some(p) = output_path_str {
2813 stats.insert("output_path".into(), json!(p));
2814 }
2815 let stats_text =
2816 serde_json::to_string_pretty(&Value::Object(stats)).unwrap_or_default();
2817
2818 let mut content = Vec::with_capacity(2);
2819 if wants_inline {
2820 let b64 = base64::engine::general_purpose::STANDARD.encode(&chart.bytes);
2821 content.push(Content::image(b64, chart.mime_type.to_string()));
2822 }
2823 content.push(Content::text(stats_text));
2824 Ok(CallToolResult::success(content))
2825 }
2826 Err(e) => Self::err_content(e),
2827 }
2828 }
2829
2830 #[tool(
2833 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."
2834 )]
2835 fn watch_directory(
2836 &self,
2837 Parameters(params): Parameters<WatchDirectoryParams>,
2838 ) -> Result<CallToolResult, rmcp::ErrorData> {
2839 if let Err(e) = self.check_writable("watch_directory") {
2840 return Self::err_content(e);
2841 }
2842 let canonical = match crate::attach::validate_input_path(¶ms.path, "watch directory") {
2843 Ok(p) => p,
2844 Err(e) => return Self::err_content(e),
2845 };
2846 match self.ensure_engine() {
2849 Ok(guard) => drop(guard),
2850 Err(e) => return Self::err_content(e),
2851 }
2852
2853 let target_db = match self.with_engine(|engine| {
2857 self.resolve_db(engine, params.database.as_deref(), params.persist, true)
2858 }) {
2859 Ok(v) => v,
2860 Err(e) => return Self::err_content(e),
2861 };
2862
2863 let path = canonical;
2864 let engine_handle = self.engine_handle();
2865 let attachments = self.attachments_handle();
2866 let registry = self.watchers_handle();
2867 let options = crate::watcher::WatchOptions {
2868 max_concurrent: params.max_concurrent.unwrap_or(0) as usize,
2869 };
2870 let result = crate::watcher::start_watching(
2871 engine_handle,
2872 attachments,
2873 registry,
2874 Some(self.subscriptions_handle()),
2875 path.clone(),
2876 params.table.clone(),
2877 target_db,
2878 options,
2879 );
2880 match result {
2881 Ok(stats) => {
2882 let body = json!({
2883 "directory": path.to_string_lossy(),
2884 "table": params.table,
2885 "status": "watching",
2886 "max_concurrent": stats.max_concurrent,
2887 "initial_sweep": {
2888 "files_ingested": stats.files_ingested,
2889 "files_failed": stats.files_failed,
2890 },
2891 });
2892 Self::ok_content(body)
2893 }
2894 Err(e) => Self::err_content(e),
2895 }
2896 }
2897
2898 #[tool(
2900 description = "Stop watching a directory previously registered with watch_directory. Pending .ready files are left in place."
2901 )]
2902 fn unwatch_directory(
2903 &self,
2904 Parameters(params): Parameters<UnwatchDirectoryParams>,
2905 ) -> Result<CallToolResult, rmcp::ErrorData> {
2906 let path = std::path::PathBuf::from(¶ms.path);
2907 let result = crate::watcher::stop_watching(&self.watchers_handle(), &path);
2908 match result {
2909 Ok(summary) => Self::ok_content(summary),
2910 Err(e) => Self::err_content(e),
2911 }
2912 }
2913
2914 #[tool(
2917 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."
2918 )]
2919 fn describe(
2920 &self,
2921 Parameters(params): Parameters<DescribeParams>,
2922 ) -> Result<CallToolResult, rmcp::ErrorData> {
2923 let result = self.with_engine(|engine| {
2924 let target_db = self.resolve_db(engine, params.database.as_deref(), None, false)?;
2925 match params.table.as_deref() {
2926 Some(name) => engine
2927 .describe_table_in(target_db.as_deref(), name)
2928 .map(|t| vec![t]),
2929 None => engine.describe_tables_in(target_db.as_deref()),
2930 }
2931 });
2932
2933 match result {
2934 Ok(tables) => Self::ok_content(json!({"tables": tables})),
2935 Err(e) => Self::err_content(e),
2936 }
2937 }
2938
2939 #[tool(
2944 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)."
2945 )]
2946 #[expect(
2947 clippy::unused_self,
2948 reason = "method retained on the type for API symmetry; implementation currently does not need state"
2949 )]
2950 fn inspect_file(
2951 &self,
2952 Parameters(params): Parameters<InspectFileParams>,
2953 ) -> Result<CallToolResult, rmcp::ErrorData> {
2954 if let Err(e) = crate::attach::validate_input_path(¶ms.path, "data file") {
2955 return Self::err_content(e);
2956 }
2957 let sample_rows = params.sample_rows.unwrap_or(5).clamp(1, 50) as usize;
2958 let result = if let Some(ref json_path) = params.json_extract_path {
2959 (|| -> Result<_, McpError> {
2960 let raw = std::fs::read_to_string(¶ms.path)
2961 .map_err(|e| McpError::from_io_error(&e, "load_file"))?;
2962 let file_size = std::fs::metadata(¶ms.path).map_or(0, |m| m.len());
2963 let extracted = crate::ingest::extract_json_path(&raw, json_path)?;
2964 crate::inspect::inspect_json_from_text(&extracted, file_size, sample_rows)
2965 })()
2966 } else {
2967 crate::inspect::inspect_source(¶ms.path, sample_rows)
2968 };
2969 match result {
2970 Ok(report) => Self::ok_content(report.to_json()),
2971 Err(e) => Self::err_content(e),
2972 }
2973 }
2974
2975 #[tool(
2978 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)."
2979 )]
2980 fn export(
2981 &self,
2982 Parameters(params): Parameters<ExportParams>,
2983 ) -> Result<CallToolResult, rmcp::ErrorData> {
2984 let result = self.with_engine(|engine| {
2985 crate::attach::validate_output_path(¶ms.path, "export")?;
2988 let format_options = match params.format_options.clone() {
2992 None => None,
2993 Some(Value::Object(m)) => Some(m),
2994 Some(other) => {
2995 return Err(McpError::new(
2996 ErrorCode::SchemaMismatch,
2997 format!("export: format_options must be a JSON object, got: {other}"),
2998 ));
2999 }
3000 };
3001 let target_db = self.resolve_db(engine, params.database.as_deref(), None, false)?;
3012 let (effective_sql, effective_table) = match (¶ms.sql, ¶ms.table, &target_db) {
3013 (None, Some(t), Some(db)) => {
3014 let esc_db = db.replace('"', "\"\"");
3015 let esc_tbl = t.replace('"', "\"\"");
3016 (
3017 Some(format!(
3018 "SELECT * FROM \"{esc_db}\".\"public\".\"{esc_tbl}\""
3019 )),
3020 None,
3021 )
3022 }
3023 _ => (params.sql.clone(), params.table.clone()),
3024 };
3025 let _search_guard = match (&effective_sql, &target_db, ¶ms.sql) {
3026 (Some(_), Some(alias), Some(_)) => Some(engine.scoped_search_path(alias)?),
3029 _ => None,
3030 };
3031 let opts = ExportOptions {
3032 sql: effective_sql,
3033 table: effective_table,
3034 path: params.path,
3035 format: params.format,
3036 overwrite: params.overwrite.unwrap_or(true),
3037 format_options,
3038 source_db: target_db.clone(),
3039 };
3040 let export_result = export_to_file(engine, &opts)?;
3041 Ok(json!({
3042 "output_path": export_result.stats.output_path,
3043 "rows": export_result.rows,
3044 "file_size_bytes": export_result.stats.file_size_bytes,
3045 "stats": export_result.stats.to_json(),
3046 }))
3047 });
3048
3049 match result {
3050 Ok(val) => Self::ok_content(val),
3051 Err(e) => Self::err_content(e),
3052 }
3053 }
3054
3055 #[tool(
3059 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."
3060 )]
3061 fn save_query(
3062 &self,
3063 Parameters(params): Parameters<SaveQueryParams>,
3064 ) -> Result<CallToolResult, rmcp::ErrorData> {
3065 if let Err(e) = self.check_writable("save_query") {
3066 return Self::err_content(e);
3067 }
3068 if !is_read_only_sql(¶ms.sql) {
3073 return Self::err_content(McpError::new(
3074 ErrorCode::SqlError,
3075 "save_query only accepts read-only SQL (SELECT / WITH / EXPLAIN / SHOW / VALUES). \
3076 Use the execute tool for DDL/DML, not save_query.",
3077 ));
3078 }
3079 if params.name.is_empty() {
3080 return Self::err_content(McpError::new(
3081 ErrorCode::SchemaMismatch,
3082 "Saved query name must not be empty.",
3083 ));
3084 }
3085 let query = SavedQuery {
3086 name: params.name.clone(),
3087 sql: params.sql,
3088 description: params.description,
3089 created_at: chrono::Utc::now(),
3090 };
3091 let store = Arc::clone(&self.saved_queries);
3092 let result = self.with_saved_query_store(|engine| store.save(engine, query.clone()));
3093 match result {
3094 Ok(()) => {
3095 self.notify_resource_list_changed();
3099 Self::ok_content(json!({
3100 "saved": true,
3101 "name": query.name,
3102 "resources": [
3103 format!("hyper://queries/{}/definition", query.name),
3104 format!("hyper://queries/{}/result", query.name),
3105 ],
3106 "created_at": query.created_at.to_rfc3339(),
3107 }))
3108 }
3109 Err(e) => Self::err_content(e),
3110 }
3111 }
3112
3113 #[tool(
3115 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)."
3116 )]
3117 fn delete_query(
3118 &self,
3119 Parameters(params): Parameters<DeleteQueryParams>,
3120 ) -> Result<CallToolResult, rmcp::ErrorData> {
3121 if let Err(e) = self.check_writable("delete_query") {
3122 return Self::err_content(e);
3123 }
3124 let store = Arc::clone(&self.saved_queries);
3125 let name = params.name.clone();
3126 let result = self.with_saved_query_store(|engine| store.delete(engine, &name));
3127 match result {
3128 Ok(deleted) => {
3129 if deleted {
3130 self.notify_resource_list_changed();
3135 self.subscriptions
3136 .notify_updated(&format!("hyper://queries/{name}/definition"));
3137 self.subscriptions
3138 .notify_updated(&format!("hyper://queries/{name}/result"));
3139 }
3140 Self::ok_content(json!({
3141 "deleted": deleted,
3142 "name": params.name,
3143 }))
3144 }
3145 Err(e) => Self::err_content(e),
3146 }
3147 }
3148
3149 #[tool(
3151 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."
3152 )]
3153 fn set_table_metadata(
3154 &self,
3155 Parameters(params): Parameters<SetTableMetadataParams>,
3156 ) -> Result<CallToolResult, rmcp::ErrorData> {
3157 if let Err(e) = self.check_writable("set_table_metadata") {
3158 return Self::err_content(e);
3159 }
3160 let fields = crate::table_catalog::MetadataFields {
3161 source_url: params.source_url,
3162 source_description: params.source_description,
3163 purpose: params.purpose,
3164 license: params.license,
3165 notes: params.notes,
3166 data_url: params.data_url,
3167 };
3168 let table_name = params.table.clone();
3169 let result = self.with_engine(|engine| {
3170 let target_db = self.resolve_db(engine, params.database.as_deref(), None, true)?;
3176 crate::table_catalog::set_metadata_in(
3177 engine,
3178 &table_name,
3179 &fields,
3180 target_db.as_deref(),
3181 )
3182 });
3183 match result {
3184 Ok(entry) => Self::ok_content(entry.to_json()),
3185 Err(e) => Self::err_content(e),
3186 }
3187 }
3188
3189 #[tool(
3191 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."
3192 )]
3193 fn kv_get(
3194 &self,
3195 Parameters(p): Parameters<KvKeyParams>,
3196 ) -> Result<CallToolResult, rmcp::ErrorData> {
3197 let result = self.with_engine(|engine| {
3198 let db = self.resolve_db(engine, p.database.as_deref(), p.persist, true)?;
3199 let kv = Self::kv_open(engine, db.as_deref(), &p.store)?;
3200 kv.get(&p.key).map_err(McpError::from)
3201 });
3202 match result {
3203 Ok(value) => Self::ok_content(json!({ "found": value.is_some(), "value": value })),
3204 Err(e) => Self::err_content(e),
3205 }
3206 }
3207
3208 #[tool(
3210 description = "KV scratchpad. Save a variable, state, summary, or JSON config under store + key to remember later without creating a database table. 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). Returns {stored, created, value_bytes}; `created:false` means an existing value was overwritten. Pass overwrite=false to avoid clobbering (skips + returns stored:false, existed:true). Pass value_path=<absolute path> to store a file's contents server-side instead of `value` (exactly one of value/value_path; reads any server-readable path — no sandbox; files over 64 MiB are rejected before reading)."
3211 )]
3212 fn kv_set(
3213 &self,
3214 Parameters(p): Parameters<KvSetParams>,
3215 ) -> Result<CallToolResult, rmcp::ErrorData> {
3216 if let Err(e) = self.check_writable("kv_set") {
3217 return Self::err_content(e);
3218 }
3219 let value = match (p.value.as_deref(), p.value_path.as_deref()) {
3221 (Some(v), None) => v.to_string(),
3222 (None, Some(path)) => {
3223 let canonical = match crate::attach::validate_input_path(path, "value_path") {
3224 Ok(c) => c,
3225 Err(e) => return Self::err_content(e),
3226 };
3227 match std::fs::metadata(&canonical) {
3230 Ok(m) => {
3231 if let Err(e) = Self::check_value_path_size(m.len()) {
3232 return Self::err_content(e);
3233 }
3234 }
3235 Err(e) => return Self::err_content(McpError::from_io_error(&e, "value_path")),
3236 }
3237 match std::fs::read_to_string(&canonical) {
3238 Ok(s) => s,
3239 Err(e) => return Self::err_content(McpError::from_io_error(&e, "value_path")),
3240 }
3241 }
3242 (Some(_), Some(_)) => {
3243 return Self::err_content(McpError::new(
3244 ErrorCode::InvalidArgument,
3245 "provide exactly one of `value` or `value_path`, not both",
3246 ));
3247 }
3248 (None, None) => {
3249 return Self::err_content(McpError::new(
3250 ErrorCode::InvalidArgument,
3251 "provide either `value` or `value_path`",
3252 ));
3253 }
3254 };
3255 let value_bytes = value.len();
3256 let overwrite = p.overwrite.unwrap_or(true);
3257
3258 let result = self.with_engine(|engine| {
3259 let db = self.resolve_db(engine, p.database.as_deref(), p.persist, true)?;
3260 let kv = Self::kv_open(engine, db.as_deref(), &p.store)?;
3261 if overwrite {
3262 kv.set(&p.key, &value)
3263 .map(|o| (true, o.created))
3264 .map_err(McpError::from)
3265 } else {
3266 kv.set_if_absent(&p.key, &value)
3267 .map(|written| (written, written))
3268 .map_err(McpError::from)
3269 }
3270 });
3271 match result {
3272 Ok((stored, created)) => {
3273 let mut body = json!({
3274 "stored": stored,
3275 "created": created,
3276 "store": p.store,
3277 "key": p.key,
3278 "value_bytes": value_bytes,
3279 });
3280 if !stored {
3281 body["existed"] = json!(true);
3282 }
3283 if let Some(w) = Self::kv_size_warning(value_bytes) {
3284 body["warning"] = json!(w);
3285 }
3286 Self::ok_content(body)
3287 }
3288 Err(e) => Self::err_content(e),
3289 }
3290 }
3291
3292 #[tool(
3294 description = "Write multiple KV pairs atomically. All keys validated before the transaction opens, so an invalid key aborts the whole batch. Returns {stored, created, overwritten, total_bytes} when overwrite=true (default); returns {stored, created, skipped, total_bytes} when overwrite=false (guard mode — skips existing keys). Empty `entries` is an error. Omit `database` to write to the ephemeral store; pass \"persistent\" (or persist=true) or an attached alias to write elsewhere."
3295 )]
3296 fn kv_set_many(
3297 &self,
3298 Parameters(p): Parameters<KvSetManyParams>,
3299 ) -> Result<CallToolResult, rmcp::ErrorData> {
3300 if let Err(e) = self.check_writable("kv_set_many") {
3301 return Self::err_content(e);
3302 }
3303 if p.entries.is_empty() {
3304 return Self::err_content(McpError::new(
3305 ErrorCode::InvalidArgument,
3306 "entries must not be empty",
3307 ));
3308 }
3309
3310 let pairs: Vec<(&str, &str)> = p
3313 .entries
3314 .iter()
3315 .map(|e| (e.key.as_str(), e.value.as_str()))
3316 .collect();
3317 let total_bytes: usize = p.entries.iter().map(|e| e.value.len()).sum();
3318 let mut warnings: Vec<serde_json::Value> = Vec::new();
3319 for entry in &p.entries {
3320 if let Some(w) = Self::kv_size_warning(entry.value.len()) {
3321 warnings.push(json!({ "key": entry.key, "warning": w }));
3322 }
3323 }
3324
3325 let overwrite = p.overwrite.unwrap_or(true);
3334 let result = self.with_engine(|engine| {
3335 let db = self.resolve_db(engine, p.database.as_deref(), p.persist, true)?;
3336 let kv = Self::kv_open(engine, db.as_deref(), &p.store)?;
3337 if overwrite {
3338 let o = kv.set_batch(&pairs).map_err(McpError::from)?;
3339 Ok(json!({
3340 "stored": o.created + o.overwritten,
3341 "created": o.created,
3342 "overwritten": o.overwritten,
3343 }))
3344 } else {
3345 let o = kv.set_batch_if_absent(&pairs).map_err(McpError::from)?;
3346 Ok(json!({
3347 "stored": o.written,
3348 "created": o.written,
3349 "skipped": o.skipped,
3350 }))
3351 }
3352 });
3353
3354 match result {
3355 Ok(mut body) => {
3356 body["total_bytes"] = json!(total_bytes);
3357 if !warnings.is_empty() {
3358 body["warnings"] = json!(warnings);
3359 }
3360 Self::ok_content(body)
3361 }
3362 Err(e) => Self::err_content(e),
3363 }
3364 }
3365
3366 #[tool(
3368 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."
3369 )]
3370 fn kv_delete(
3371 &self,
3372 Parameters(p): Parameters<KvKeyParams>,
3373 ) -> Result<CallToolResult, rmcp::ErrorData> {
3374 if let Err(e) = self.check_writable("kv_delete") {
3375 return Self::err_content(e);
3376 }
3377 let result = self.with_engine(|engine| {
3378 let db = self.resolve_db(engine, p.database.as_deref(), p.persist, true)?;
3379 let kv = Self::kv_open(engine, db.as_deref(), &p.store)?;
3380 kv.delete(&p.key).map_err(McpError::from)
3381 });
3382 match result {
3383 Ok(deleted) => {
3384 Self::ok_content(json!({ "deleted": deleted, "store": p.store, "key": p.key }))
3385 }
3386 Err(e) => Self::err_content(e),
3387 }
3388 }
3389
3390 #[tool(
3392 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. Pass values=true to return full (key, value) pairs as an `entries` array instead of just keys — useful for reading a whole store without N×kv_get."
3393 )]
3394 fn kv_list(
3395 &self,
3396 Parameters(p): Parameters<KvListParams>,
3397 ) -> Result<CallToolResult, rmcp::ErrorData> {
3398 let with_values = p.values.unwrap_or(false);
3399 let result = self.with_engine(|engine| {
3400 let db = self.resolve_db(engine, p.database.as_deref(), p.persist, true)?;
3401 let kv = Self::kv_open(engine, db.as_deref(), &p.store)?;
3402 if with_values {
3403 kv.entries().map(|pairs| (Some(pairs), None))
3404 } else {
3405 kv.keys().map(|keys| (None, Some(keys)))
3406 }
3407 .map_err(McpError::from)
3408 });
3409 match result {
3410 Ok((Some(entries), None)) => {
3411 let arr: Vec<Value> = entries
3412 .into_iter()
3413 .map(|(k, v)| json!({ "key": k, "value": v }))
3414 .collect();
3415 Self::ok_content(json!({ "store": p.store, "entries": arr }))
3416 }
3417 Ok((None, Some(keys))) => {
3418 Self::ok_content(json!({ "store": p.store, "count": keys.len(), "keys": keys }))
3419 }
3420 Ok(_) => unreachable!("exactly one of entries/keys is Some"),
3421 Err(e) => Self::err_content(e),
3422 }
3423 }
3424
3425 #[tool(
3427 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."
3428 )]
3429 fn kv_list_stores(
3430 &self,
3431 Parameters(p): Parameters<KvListStoresParams>,
3432 ) -> Result<CallToolResult, rmcp::ErrorData> {
3433 let result = self.with_engine(|engine| {
3434 let db = self.resolve_db(engine, p.database.as_deref(), p.persist, true)?;
3435 match db.as_deref() {
3436 Some(alias) => engine.connection().kv_list_stores_in(alias),
3437 None => engine.connection().kv_list_stores(),
3438 }
3439 .map_err(McpError::from)
3440 });
3441 match result {
3442 Ok(stores) => Self::ok_content(json!({ "count": stores.len(), "stores": stores })),
3443 Err(e) => Self::err_content(e),
3444 }
3445 }
3446
3447 #[tool(
3449 description = "Returns {store, size, bytes} where `size` is the key count and `bytes` is the total `OCTET_LENGTH` of all values (0 for empty stores). Omit `database` for the ephemeral store, or route with \"persistent\"/persist=true/an attached alias."
3450 )]
3451 fn kv_size(
3452 &self,
3453 Parameters(p): Parameters<KvStoreParams>,
3454 ) -> Result<CallToolResult, rmcp::ErrorData> {
3455 let result = self.with_engine(|engine| {
3456 let db = self.resolve_db(engine, p.database.as_deref(), p.persist, true)?;
3457 let kv = Self::kv_open(engine, db.as_deref(), &p.store)?;
3458 let key_count = kv.size().map_err(McpError::from)?;
3459 let value_bytes = kv.byte_size().map_err(McpError::from)?;
3460 Ok(json!({
3461 "store": p.store,
3462 "size": key_count,
3463 "bytes": value_bytes,
3464 }))
3465 });
3466 match result {
3467 Ok(val) => Self::ok_content(val),
3468 Err(e) => Self::err_content(e),
3469 }
3470 }
3471
3472 #[tool(
3475 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."
3476 )]
3477 fn kv_pop(
3478 &self,
3479 Parameters(p): Parameters<KvStoreParams>,
3480 ) -> Result<CallToolResult, rmcp::ErrorData> {
3481 if let Err(e) = self.check_writable("kv_pop") {
3482 return Self::err_content(e);
3483 }
3484 let result = self.with_engine(|engine| {
3485 let db = self.resolve_db(engine, p.database.as_deref(), p.persist, true)?;
3486 let kv = Self::kv_open(engine, db.as_deref(), &p.store)?;
3487 kv.pop().map_err(McpError::from)
3488 });
3489 match result {
3490 Ok(Some((key, value))) => {
3491 Self::ok_content(json!({ "found": true, "key": key, "value": value }))
3492 }
3493 Ok(None) => Self::ok_content(json!({ "found": false })),
3494 Err(e) => Self::err_content(e),
3495 }
3496 }
3497
3498 #[tool(
3500 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."
3501 )]
3502 fn kv_clear(
3503 &self,
3504 Parameters(p): Parameters<KvStoreParams>,
3505 ) -> Result<CallToolResult, rmcp::ErrorData> {
3506 if let Err(e) = self.check_writable("kv_clear") {
3507 return Self::err_content(e);
3508 }
3509 let result = self.with_engine(|engine| {
3510 let db = self.resolve_db(engine, p.database.as_deref(), p.persist, true)?;
3511 let kv = Self::kv_open(engine, db.as_deref(), &p.store)?;
3512 kv.clear().map_err(McpError::from)
3513 });
3514 match result {
3515 Ok(removed) => Self::ok_content(json!({ "store": p.store, "removed": removed })),
3516 Err(e) => Self::err_content(e),
3517 }
3518 }
3519
3520 #[tool(
3524 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."
3525 )]
3526 fn status(&self) -> Result<CallToolResult, rmcp::ErrorData> {
3527 let Ok(guard) = self.engine.try_lock() else {
3540 return Self::ok_content(self.status_degraded());
3541 };
3542 let Some(engine) = guard.as_ref() else {
3543 return Self::ok_content(self.status_degraded());
3544 };
3545 self.ensure_catalog_ready(engine);
3546 let result = engine.status();
3547
3548 match result {
3549 Ok(mut val) => {
3550 if let Some(obj) = val.as_object_mut() {
3551 obj.insert("engine_busy".into(), json!(false));
3552 obj.insert("watchers".into(), self.watchers.to_json());
3553 obj.insert("read_only".into(), json!(self.read_only));
3554 let attachments: Vec<Value> = self
3555 .attachments
3556 .list()
3557 .iter()
3558 .map(super::attach::AttachedDb::to_json)
3559 .collect();
3560 obj.insert("attachments".into(), Value::Array(attachments));
3561 }
3562 Self::ok_content(val)
3563 }
3564 Err(e) => Self::err_content(e),
3565 }
3566 }
3567
3568 #[tool(
3573 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."
3574 )]
3575 #[expect(
3576 clippy::unused_self,
3577 reason = "the #[tool] macro dispatches on &self; signature must match the rest of the tool surface even though this tool is stateless"
3578 )]
3579 #[expect(
3580 clippy::unnecessary_wraps,
3581 reason = "uniform Result<CallToolResult, rmcp::ErrorData> across all tools so the #[tool_router] dispatcher has one signature shape"
3582 )]
3583 fn get_readme(&self) -> Result<CallToolResult, rmcp::ErrorData> {
3584 Ok(CallToolResult::success(vec![Content::text(
3585 crate::readme::README,
3586 )]))
3587 }
3588
3589 #[tool(
3592 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."
3593 )]
3594 fn attach_database(
3595 &self,
3596 Parameters(params): Parameters<AttachDatabaseParams>,
3597 ) -> Result<CallToolResult, rmcp::ErrorData> {
3598 let writable = params.writable.unwrap_or(false);
3599 if writable {
3600 if let Err(e) = self.check_writable("attach_database(writable)") {
3601 return Self::err_content(e);
3602 }
3603 }
3604 let on_missing = match attach::OnMissing::parse(params.on_missing.as_deref()) {
3605 Ok(v) => v,
3606 Err(e) => return Self::err_content(e),
3607 };
3608 if on_missing == attach::OnMissing::Create && !writable {
3609 return Self::err_content(McpError::new(
3610 ErrorCode::InvalidArgument,
3611 "on_missing='create' requires writable:true — an empty .hyper file that cannot be written to cannot be populated.",
3612 ));
3613 }
3614 let source = match params.kind.as_str() {
3615 "local_file" => {
3616 let Some(raw) = params.path.as_deref() else {
3617 return Self::err_content(McpError::new(
3618 ErrorCode::InvalidArgument,
3619 "kind='local_file' requires a 'path' argument",
3620 ));
3621 };
3622 let resolved = match on_missing {
3623 attach::OnMissing::Error => attach::validate_local_path(raw),
3624 attach::OnMissing::Create => attach::validate_local_path_for_create(raw),
3625 };
3626 match resolved {
3627 Ok(canonical) => AttachSource::LocalFile { path: canonical },
3628 Err(e) => return Self::err_content(e),
3629 }
3630 }
3631 other => {
3632 return Self::err_content(McpError::new(
3633 ErrorCode::InvalidArgument,
3634 format!(
3635 "Unsupported attach kind '{other}'. Only 'local_file' is supported today; \
3636 'tcp' (remote hyperd) and 'grpc' (Data 360) are planned."
3637 ),
3638 ));
3639 }
3640 };
3641 let req = AttachRequest {
3642 alias: params.alias.clone(),
3643 source,
3644 writable,
3645 on_missing,
3646 };
3647 let registry = self.attachments_handle();
3648 let alias_for_probe = req.alias.clone();
3649 let result = self.with_engine(|engine| {
3650 let entry = registry.attach(engine, req.clone())?;
3651 let tables_visible = probe_table_count(engine, &alias_for_probe);
3656 Ok(json!({
3657 "alias": entry.alias,
3658 "kind": entry.source.kind_str(),
3659 "source": entry.source.to_json(),
3660 "writable": entry.writable,
3661 "tables_visible": tables_visible,
3662 }))
3663 });
3664 match result {
3665 Ok(val) => Self::ok_content(val),
3666 Err(e) => Self::err_content(e),
3667 }
3668 }
3669
3670 #[tool(
3672 description = "Detach a database previously registered with attach_database. No-op when the alias is unknown. Returns {detached: true/false}."
3673 )]
3674 fn detach_database(
3675 &self,
3676 Parameters(params): Parameters<DetachDatabaseParams>,
3677 ) -> Result<CallToolResult, rmcp::ErrorData> {
3678 let alias = params.alias.to_ascii_lowercase();
3683 if let Ok(watchers) = self.watchers.watchers.lock() {
3689 let conflict = watchers
3690 .values()
3691 .find(|h| h.target_db.as_deref() == Some(alias.as_str()));
3692 if let Some(h) = conflict {
3693 return Self::err_content(McpError::new(
3694 ErrorCode::InvalidArgument,
3695 format!(
3696 "cannot detach '{alias}': an active watcher on directory '{}' targets it. \
3697 Call unwatch_directory(\"{}\") first.",
3698 h.directory.display(),
3699 h.directory.display()
3700 ),
3701 ));
3702 }
3703 }
3704 let registry = self.attachments_handle();
3705 let result = self.with_engine(|engine| {
3706 let outcome = registry.detach(engine, &alias)?;
3707 if outcome {
3708 engine.clear_catalog_cache_for(&alias);
3712 }
3713 Ok(outcome)
3714 });
3715 match result {
3716 Ok(detached) => {
3717 Self::ok_content(json!({ "alias": params.alias, "detached": detached }))
3718 }
3719 Err(e) => Self::err_content(e),
3720 }
3721 }
3722
3723 #[tool(
3733 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."
3734 )]
3735 fn list_attached_databases(&self) -> Result<CallToolResult, rmcp::ErrorData> {
3736 let result = self.with_engine(|engine| {
3737 let entries = self.attachments.list();
3738 let attachments: Vec<Value> = entries
3739 .iter()
3740 .map(|entry| {
3741 let mut obj = entry.to_json();
3742 let tables_visible = probe_table_count(engine, &entry.alias);
3743 if let Some(map) = obj.as_object_mut() {
3744 map.insert("tables_visible".into(), json!(tables_visible));
3745 }
3746 obj
3747 })
3748 .collect();
3749 Ok(json!({ "attachments": attachments }))
3750 });
3751 match result {
3752 Ok(val) => Self::ok_content(val),
3753 Err(e) => Self::err_content(e),
3754 }
3755 }
3756
3757 #[tool(
3762 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."
3763 )]
3764 fn copy_query(
3765 &self,
3766 Parameters(params): Parameters<CopyQueryParams>,
3767 ) -> Result<CallToolResult, rmcp::ErrorData> {
3768 if let Err(e) = self.check_writable("copy_query") {
3769 return Self::err_content(e);
3770 }
3771 let mode = match params.mode.as_str() {
3772 "create" | "append" | "replace" => params.mode.clone(),
3773 other => {
3774 return Self::err_content(McpError::new(
3775 ErrorCode::InvalidArgument,
3776 format!(
3777 "copy_query mode '{other}' is not supported. Use 'create', 'append', or 'replace'."
3778 ),
3779 ));
3780 }
3781 };
3782 if !is_read_only_sql(¶ms.sql) {
3783 return Self::err_content(McpError::new(
3784 ErrorCode::SqlError,
3785 "copy_query's `sql` must be a read-only statement (SELECT / WITH / VALUES). \
3786 Use the execute tool for raw DDL/DML.",
3787 ));
3788 }
3789 let target_db_owned = params
3801 .target_database
3802 .as_deref()
3803 .filter(|s| !s.eq_ignore_ascii_case(LOCAL_ALIAS))
3804 .map(str::to_ascii_lowercase);
3805 let target_db = target_db_owned.as_deref();
3806 if let Some(alias) = target_db {
3807 match self.attachments.get(alias) {
3808 None => {
3809 return Self::err_content(McpError::new(
3810 ErrorCode::InvalidArgument,
3811 format!(
3812 "target_database '{alias}' is not attached. Call attach_database first."
3813 ),
3814 ));
3815 }
3816 Some(entry) if !entry.writable => {
3817 return Self::err_content(McpError::new(
3818 ErrorCode::InvalidArgument,
3819 format!(
3820 "target_database '{alias}' was attached read-only. Re-attach with writable:true to use it as a copy target."
3821 ),
3822 ));
3823 }
3824 Some(_) => {}
3825 }
3826 }
3827
3828 let temp_specs = params.temp_attach.clone().unwrap_or_default();
3831 let prepared_temps = match prepare_temp_attachments(&temp_specs, self.is_read_only()) {
3832 Ok(v) => v,
3833 Err(e) => return Self::err_content(e),
3834 };
3835
3836 let target_table = params.target_table.clone();
3837 let sql_body = params.sql.clone();
3838 let load_params = serde_json::to_string(&json!({
3839 "mode": mode,
3840 "target_database": params.target_database,
3841 "target_table": target_table,
3842 "sql": Self::fmt_sql(&sql_body),
3843 }))
3844 .ok();
3845
3846 let registry = self.attachments_handle();
3847 let result = self.with_engine(|engine| {
3848 let mut temp_aliases: Vec<String> = Vec::new();
3850 for req in &prepared_temps {
3851 match registry.attach(engine, req.clone()) {
3852 Ok(entry) => temp_aliases.push(entry.alias),
3853 Err(e) => {
3854 for alias in &temp_aliases {
3856 let _ = registry.detach(engine, alias);
3857 }
3858 return Err(e);
3859 }
3860 }
3861 }
3862
3863 let copy_outcome = perform_copy(engine, &mode, target_db, &target_table, &sql_body);
3866
3867 for alias in &temp_aliases {
3871 if let Err(e) = registry.detach(engine, alias) {
3872 tracing::warn!(
3873 alias = %alias,
3874 err = %e.message,
3875 "failed to detach temp attachment after copy_query",
3876 );
3877 }
3878 }
3879
3880 if copy_outcome.is_ok() && target_db.is_none() {
3891 let row_count = copy_outcome
3892 .as_ref()
3893 .ok()
3894 .and_then(|v| v.get("row_count").and_then(serde_json::Value::as_i64));
3895 self.after_ingest_catalog_update(
3896 engine,
3897 &target_table,
3898 "copy_query",
3899 load_params.as_deref(),
3900 row_count,
3901 target_db,
3902 );
3903 }
3904
3905 copy_outcome
3906 });
3907
3908 match result {
3909 Ok(outcome) => {
3910 if target_db.is_none() {
3912 self.notify_table_changed(&target_table);
3913 }
3914 self.notify_workspace_changed();
3915 if mode != "append" {
3916 self.notify_resource_list_changed();
3919 }
3920 Self::ok_content(outcome)
3921 }
3922 Err(e) => Self::err_content(e),
3923 }
3924 }
3925}
3926
3927#[prompt_router]
3930impl HyperMcpServer {
3931 #[prompt(
3933 name = "analyze-table",
3934 description = "Deep analysis of a single table: schema, sample, column stats, data quality"
3935 )]
3936 pub async fn analyze_table(
3937 &self,
3938 Parameters(args): Parameters<AnalyzeTableArgs>,
3939 ) -> Vec<PromptMessage> {
3940 let context = self.build_analyze_context(&args.table);
3941 vec![
3942 PromptMessage::new_text(
3943 PromptMessageRole::User,
3944 format!(
3945 "Analyze the `{}` table thoroughly.\n\n{}\n\nPlease:\n\
3946 1. Describe each column (what it likely represents based on name and sample values)\n\
3947 2. Compute basic statistics using the query tool: min/max/avg for numeric columns, distinct count and top values for text columns\n\
3948 3. Flag any data quality issues: unexpected NULLs, suspicious outliers, inconsistent formats\n\
3949 4. Summarize your findings in plain English",
3950 args.table, context
3951 ),
3952 ),
3953 PromptMessage::new_text(
3954 PromptMessageRole::Assistant,
3955 format!(
3956 "I'll analyze the `{}` table systematically. Let me start by examining the schema and sample, then run targeted queries for statistics and data quality.",
3957 args.table
3958 ),
3959 ),
3960 ]
3961 }
3962
3963 #[prompt(
3965 name = "compare-tables",
3966 description = "Compare two tables: schema alignment, common keys, JOIN opportunities"
3967 )]
3968 pub async fn compare_tables(
3969 &self,
3970 Parameters(args): Parameters<CompareTablesArgs>,
3971 ) -> Vec<PromptMessage> {
3972 let ctx_a = self.build_brief_context(&args.table_a);
3973 let ctx_b = self.build_brief_context(&args.table_b);
3974 vec![
3975 PromptMessage::new_text(
3976 PromptMessageRole::User,
3977 format!(
3978 "Compare these two tables:\n\n## Table A: `{}`\n{}\n\n## Table B: `{}`\n{}\n\nPlease:\n\
3979 1. Identify columns that appear in both tables (by name or semantic match)\n\
3980 2. Suggest likely JOIN keys and the JOIN type (inner, left, etc.)\n\
3981 3. Highlight schema differences (column types, nullability)\n\
3982 4. Propose 3-5 analytical queries that combine both tables and explain what each reveals",
3983 args.table_a, ctx_a, args.table_b, ctx_b
3984 ),
3985 ),
3986 PromptMessage::new_text(
3987 PromptMessageRole::Assistant,
3988 format!(
3989 "I'll compare `{}` and `{}` systematically — schema alignment first, then join keys, then analytical opportunities.",
3990 args.table_a, args.table_b
3991 ),
3992 ),
3993 ]
3994 }
3995
3996 #[prompt(
3998 name = "data-quality",
3999 description = "Systematic data quality assessment: NULL rates, duplicates, low cardinality, outliers"
4000 )]
4001 pub async fn data_quality(
4002 &self,
4003 Parameters(args): Parameters<DataQualityArgs>,
4004 ) -> Vec<PromptMessage> {
4005 let context = self.build_brief_context(&args.table);
4006 vec![
4007 PromptMessage::new_text(
4008 PromptMessageRole::User,
4009 format!(
4010 "Run a data quality assessment on the `{}` table.\n\n{}\n\nPlease use the query tool to check:\n\
4011 1. NULL rate per column — run SELECT COUNT(*) FILTER (WHERE col IS NULL) / COUNT(*) for each column\n\
4012 2. Duplicate rows — compare COUNT(*) vs COUNT(DISTINCT *) or use GROUP BY\n\
4013 3. Low-cardinality columns — columns with suspiciously few distinct values\n\
4014 4. Numeric outliers — values more than 3 stddev from the mean\n\
4015 5. Date sanity — future dates or impossibly old dates in date/timestamp columns\n\n\
4016 Summarize findings with severity (critical / warning / info) and suggest remediation for each issue.",
4017 args.table, context
4018 ),
4019 ),
4020 PromptMessage::new_text(
4021 PromptMessageRole::Assistant,
4022 format!(
4023 "I'll perform a systematic data quality assessment on `{}`. Let me run targeted queries for each check category.",
4024 args.table
4025 ),
4026 ),
4027 ]
4028 }
4029
4030 #[prompt(
4032 name = "suggest-queries",
4033 description = "Suggest analytical SQL queries for a table, optionally guided by a goal"
4034 )]
4035 pub async fn suggest_queries(
4036 &self,
4037 Parameters(args): Parameters<SuggestQueriesArgs>,
4038 ) -> Vec<PromptMessage> {
4039 let context = self.build_analyze_context(&args.table);
4040 let goal_section = match args.goal.as_deref() {
4041 Some(g) if !g.is_empty() => format!("\n\nSpecific goal: {g}"),
4042 _ => String::new(),
4043 };
4044 vec![
4045 PromptMessage::new_text(
4046 PromptMessageRole::User,
4047 format!(
4048 "Given the `{}` table:\n\n{}{}\n\nSuggest 5 analytical SQL queries that would be useful for exploring this data. \
4049 For each query, provide:\n\
4050 - A descriptive title\n\
4051 - The exact SQL (valid for Hyper / PostgreSQL-compatible syntax)\n\
4052 - One sentence explaining what insight it reveals\n\n\
4053 Prefer queries that use aggregations, GROUP BY, window functions, or CTEs to demonstrate the power of SQL analytics.",
4054 args.table, context, goal_section
4055 ),
4056 ),
4057 PromptMessage::new_text(
4058 PromptMessageRole::Assistant,
4059 format!(
4060 "Based on the schema and sample of `{}`, here are 5 analytical queries.",
4061 args.table
4062 ),
4063 ),
4064 ]
4065 }
4066}
4067
4068#[derive(Debug, Clone)]
4077pub enum ResourceBody {
4078 Json(Value),
4080 Text {
4083 mime_type: String,
4085 content: String,
4087 },
4088}
4089
4090impl ResourceBody {
4091 #[must_use]
4093 pub fn mime_type(&self) -> &str {
4094 match self {
4095 ResourceBody::Json(_) => "application/json",
4096 ResourceBody::Text { mime_type, .. } => mime_type,
4097 }
4098 }
4099
4100 #[must_use]
4103 pub fn to_text(&self) -> String {
4104 match self {
4105 ResourceBody::Json(v) => {
4106 serde_json::to_string_pretty(v).unwrap_or_else(|_| v.to_string())
4107 }
4108 ResourceBody::Text { content, .. } => content.clone(),
4109 }
4110 }
4111
4112 #[must_use]
4115 pub fn as_json(&self) -> Option<&Value> {
4116 match self {
4117 ResourceBody::Json(v) => Some(v),
4118 ResourceBody::Text { .. } => None,
4119 }
4120 }
4121}
4122
4123impl HyperMcpServer {
4124 pub fn resource_body_for_uri(&self, uri: &str) -> Result<Option<ResourceBody>, McpError> {
4143 if uri == "hyper://workspace" {
4144 return self
4145 .with_engine(super::engine::Engine::status)
4146 .map(|v| Some(ResourceBody::Json(v)));
4147 }
4148 if uri == "hyper://tables" {
4149 return self
4150 .with_engine(|engine| {
4151 engine
4152 .describe_tables()
4153 .map(|tables| json!({ "tables": tables }))
4154 })
4155 .map(|v| Some(ResourceBody::Json(v)));
4156 }
4157 if uri == "hyper://readme" {
4158 return self.build_readme_body().map(Some);
4159 }
4160 if uri == "hyper://schema/kv" {
4161 return Ok(Some(ResourceBody::Text {
4162 mime_type: "text/plain".into(),
4163 content: KV_SCHEMA_RESOURCE.to_string(),
4164 }));
4165 }
4166 if let Some(name) = uri
4167 .strip_prefix("hyper://tables/")
4168 .and_then(|rest| rest.strip_suffix("/schema"))
4169 {
4170 let name = name.to_string();
4171 return self
4172 .with_engine(|engine| {
4173 let tables = engine.describe_tables()?;
4174 tables
4175 .into_iter()
4176 .find(|t| t.get("name").and_then(|v| v.as_str()) == Some(name.as_str()))
4177 .ok_or_else(|| {
4178 McpError::new(
4179 ErrorCode::TableNotFound,
4180 format!("Table '{name}' does not exist"),
4181 )
4182 })
4183 })
4184 .map(|v| Some(ResourceBody::Json(v)));
4185 }
4186 if let Some(name) = uri
4187 .strip_prefix("hyper://tables/")
4188 .and_then(|rest| rest.strip_suffix("/sample"))
4189 {
4190 let name = name.to_string();
4191 return self
4192 .with_engine(|engine| engine.sample_table(&name, TABLE_SAMPLE_ROWS))
4193 .map(|v| Some(ResourceBody::Json(v)));
4194 }
4195 if let Some(name) = uri
4196 .strip_prefix("hyper://tables/")
4197 .and_then(|rest| rest.strip_suffix("/csv-sample"))
4198 {
4199 let name = name.to_string();
4200 return self.build_csv_sample_body(&name).map(Some);
4201 }
4202 if let Some(name) = uri
4203 .strip_prefix("hyper://queries/")
4204 .and_then(|rest| rest.strip_suffix("/definition"))
4205 {
4206 return self.build_saved_query_definition(name).map(Some);
4207 }
4208 if let Some(name) = uri
4209 .strip_prefix("hyper://queries/")
4210 .and_then(|rest| rest.strip_suffix("/result"))
4211 {
4212 return self.build_saved_query_result(name).map(Some);
4213 }
4214 Ok(None)
4215 }
4216
4217 fn build_saved_query_definition(&self, name: &str) -> Result<ResourceBody, McpError> {
4221 let store = Arc::clone(&self.saved_queries);
4222 let name = name.to_string();
4223 let query = self.with_saved_query_store(|engine| store.get(engine, &name))?;
4224 match query {
4225 Some(q) => Ok(ResourceBody::Json(q.to_json())),
4226 None => Err(McpError::new(
4227 ErrorCode::TableNotFound,
4228 format!("No saved query named '{name}'"),
4229 )),
4230 }
4231 }
4232
4233 fn build_saved_query_result(&self, name: &str) -> Result<ResourceBody, McpError> {
4238 let store = Arc::clone(&self.saved_queries);
4239 let name_owned = name.to_string();
4240 let query = self
4241 .with_saved_query_store(|engine| store.get(engine, &name_owned))?
4242 .ok_or_else(|| {
4243 McpError::new(
4244 ErrorCode::TableNotFound,
4245 format!("No saved query named '{name_owned}'"),
4246 )
4247 })?;
4248 let sql = query.sql.clone();
4249 let body = self.with_engine(|engine| {
4250 let timer = crate::stats::StatsTimer::start();
4251 let rows = engine.execute_query_to_json(&sql)?;
4252 let elapsed = timer.elapsed_ms();
4253 let result_size = serde_json::to_string(&rows).map_or(0, |s| s.len() as u64);
4254 let stats = crate::stats::QueryStats {
4255 operation: "saved_query".into(),
4256 rows_returned: rows.len() as u64,
4257 rows_scanned: 0,
4258 elapsed_ms: elapsed,
4259 result_size_bytes: result_size,
4260 tables_touched: vec![],
4261 };
4262 Ok(json!({
4263 "name": query.name,
4264 "sql": Self::fmt_sql(&query.sql),
4265 "result": rows,
4266 "stats": stats.to_json(),
4267 }))
4268 })?;
4269 Ok(ResourceBody::Json(body))
4270 }
4271
4272 #[must_use]
4280 pub fn list_resource_uris(&self) -> Vec<String> {
4281 let mut uris = vec![
4282 "hyper://workspace".to_string(),
4283 "hyper://tables".to_string(),
4284 "hyper://readme".to_string(),
4285 "hyper://schema/kv".to_string(),
4286 ];
4287 if let Ok(tables) = self.with_engine(super::engine::Engine::describe_tables) {
4288 for table in tables {
4292 if let Some(name) = table.get("name").and_then(|v| v.as_str()) {
4293 uris.push(format!("hyper://tables/{name}/schema"));
4294 uris.push(format!("hyper://tables/{name}/sample"));
4295 uris.push(format!("hyper://tables/{name}/csv-sample"));
4296 }
4297 }
4298 }
4299 let store = Arc::clone(&self.saved_queries);
4300 if let Ok(saved) = self.with_saved_query_store(|engine| store.list(engine)) {
4301 for q in saved {
4302 uris.push(format!("hyper://queries/{}/definition", q.name));
4303 uris.push(format!("hyper://queries/{}/result", q.name));
4304 }
4305 }
4306 uris
4307 }
4308
4309 fn build_readme_body(&self) -> Result<ResourceBody, McpError> {
4317 let status = self.with_engine(super::engine::Engine::status)?;
4318 let tables = self
4319 .with_engine(super::engine::Engine::describe_tables)
4320 .unwrap_or_default();
4321
4322 let workspace_mode = status
4323 .get("workspace_mode")
4324 .and_then(|v| v.as_str())
4325 .unwrap_or("unknown");
4326 let workspace_path = status
4327 .get("workspace_path")
4328 .and_then(|v| v.as_str())
4329 .unwrap_or("");
4330 let read_only = status
4331 .get("read_only")
4332 .and_then(serde_json::Value::as_bool)
4333 .unwrap_or(false);
4334 let table_count = tables.len();
4335
4336 let mut md = String::new();
4337 md.push_str("# HyperDB workspace\n\n");
4338 let _ = writeln!(
4339 md,
4340 "- Mode: **{workspace_mode}**{}\n",
4341 if read_only { " (read-only)" } else { "" }
4342 );
4343 if !workspace_path.is_empty() {
4344 let _ = writeln!(md, "- Path: `{workspace_path}`\n");
4345 }
4346 let _ = write!(md, "- Tables: **{table_count}**\n\n");
4347
4348 if tables.is_empty() {
4349 md.push_str(
4350 "_No tables loaded yet._ Use the `load_file` or `load_data` tools to \
4351 ingest CSV / JSON / Parquet / Arrow IPC data; call `inspect_file` \
4352 first if you're unsure of the schema.\n",
4353 );
4354 } else {
4355 md.push_str("## Tables\n\n");
4356 md.push_str("| Table | Rows | Columns |\n");
4357 md.push_str("|---|---:|---|\n");
4358 for t in &tables {
4359 let name = t.get("name").and_then(|v| v.as_str()).unwrap_or("?");
4360 let rows = t
4361 .get("row_count")
4362 .and_then(serde_json::Value::as_i64)
4363 .unwrap_or(0);
4364 let cols: Vec<String> = t
4365 .get("columns")
4366 .and_then(|v| v.as_array())
4367 .map(|arr| {
4368 arr.iter()
4369 .filter_map(|c| {
4370 let n = c.get("name")?.as_str()?;
4371 let ty = c.get("type")?.as_str()?;
4372 Some(format!("`{n}` {ty}"))
4373 })
4374 .collect()
4375 })
4376 .unwrap_or_default();
4377 let _ = writeln!(md, "| `{name}` | {rows} | {} |\n", cols.join(", "));
4378 }
4379 md.push('\n');
4380 md.push_str("## Related resources\n\n");
4381 for t in &tables {
4382 if let Some(name) = t.get("name").and_then(|v| v.as_str()) {
4383 let _ = write!(md, "- `hyper://tables/{name}/schema` — JSON schema and row count\n\
4384 - `hyper://tables/{name}/sample` — first {TABLE_SAMPLE_ROWS} rows as JSON\n\
4385 - `hyper://tables/{name}/csv-sample` — first {TABLE_CSV_SAMPLE_ROWS} rows as CSV\n");
4386 }
4387 }
4388 md.push('\n');
4389 }
4390
4391 md.push_str(
4392 "## Tool hints\n\n\
4393 - `query(sql)` — read-only SQL (SELECT / WITH / EXPLAIN / SHOW / VALUES).\n\
4394 - `execute(sql)` — DDL/DML (disabled in read-only mode).\n\
4395 - `sample(table, n)` — configurable row sample; the fixed-size\n \
4396 `hyper://tables/{name}/sample` resource uses n=5.\n\
4397 - `inspect_file(path)` — dry-run schema inference before loading.\n\
4398 - `chart(sql, chart_type, ...)` — render a PNG/SVG from a query.\n\
4399 - `export(sql|table, path, format)` — write to CSV / Parquet / Arrow IPC / .hyper.\n",
4400 );
4401
4402 Ok(ResourceBody::Text {
4403 mime_type: "text/markdown".into(),
4404 content: md,
4405 })
4406 }
4407
4408 fn build_csv_sample_body(&self, table: &str) -> Result<ResourceBody, McpError> {
4412 let sample =
4413 self.with_engine(|engine| engine.sample_table(table, TABLE_CSV_SAMPLE_ROWS))?;
4414
4415 let header: Vec<String> = sample
4419 .get("schema")
4420 .and_then(|v| v.as_array())
4421 .map(|cols| {
4422 cols.iter()
4423 .filter_map(|c| c.get("name").and_then(|n| n.as_str()).map(String::from))
4424 .collect()
4425 })
4426 .filter(|v: &Vec<String>| !v.is_empty())
4427 .or_else(|| {
4428 sample
4429 .get("rows")
4430 .and_then(|v| v.as_array())
4431 .and_then(|rows| rows.first())
4432 .and_then(|r| r.as_object())
4433 .map(|o| o.keys().cloned().collect())
4434 })
4435 .unwrap_or_default();
4436
4437 let mut wtr = csv::Writer::from_writer(Vec::<u8>::new());
4438 if !header.is_empty() {
4439 wtr.write_record(&header).map_err(|e| {
4440 McpError::new(
4441 ErrorCode::InternalError,
4442 format!("Failed to write CSV header: {e}"),
4443 )
4444 })?;
4445 }
4446 if let Some(rows) = sample.get("rows").and_then(|v| v.as_array()) {
4447 for row in rows {
4448 let record: Vec<String> = header
4449 .iter()
4450 .map(|col| row.get(col).map(value_to_csv_cell).unwrap_or_default())
4451 .collect();
4452 wtr.write_record(&record).map_err(|e| {
4453 McpError::new(
4454 ErrorCode::InternalError,
4455 format!("Failed to write CSV row: {e}"),
4456 )
4457 })?;
4458 }
4459 }
4460 let bytes = wtr.into_inner().map_err(|e| {
4461 McpError::new(
4462 ErrorCode::InternalError,
4463 format!("Failed to finalize CSV: {e}"),
4464 )
4465 })?;
4466 let content = String::from_utf8(bytes).map_err(|e| {
4467 McpError::new(
4468 ErrorCode::InternalError,
4469 format!("CSV produced invalid UTF-8: {e}"),
4470 )
4471 })?;
4472
4473 Ok(ResourceBody::Text {
4474 mime_type: "text/csv".into(),
4475 content,
4476 })
4477 }
4478
4479 fn build_analyze_context(&self, table: &str) -> String {
4482 match self.with_engine(|engine| engine.sample_table(table, 10)) {
4483 Ok(sample) => format!(
4484 "Schema and sample:\n```json\n{}\n```",
4485 serde_json::to_string_pretty(&sample).unwrap_or_else(|_| sample.to_string())
4486 ),
4487 Err(e) => format!("(Could not load table context: {e})"),
4488 }
4489 }
4490
4491 fn build_brief_context(&self, table: &str) -> String {
4493 match self.with_engine(|engine| engine.sample_table(table, 5)) {
4494 Ok(sample) => format!(
4495 "```json\n{}\n```",
4496 serde_json::to_string_pretty(&sample).unwrap_or_else(|_| sample.to_string())
4497 ),
4498 Err(e) => format!("(Could not load table context: {e})"),
4499 }
4500 }
4501}
4502
4503#[tool_handler]
4506#[prompt_handler]
4507impl ServerHandler for HyperMcpServer {
4508 fn get_info(&self) -> ServerInfo {
4509 let sql_dialect = "\n\
4510\n\
4511SQL DIALECT — Salesforce Data Cloud SQL (PostgreSQL-compatible with extensions).\n\
4512Key differences from standard PostgreSQL an LLM should know:\n\
4513\n\
4514TYPES\n\
4515- Supported: SMALLINT, INTEGER/INT, BIGINT, REAL/FLOAT4, DOUBLE PRECISION/FLOAT8,\n\
4516 NUMERIC(p,s)/DECIMAL(p,s), BOOLEAN, TEXT, CHAR(n), VARCHAR(n), BYTES,\n\
4517 DATE, TIME, TIMESTAMP, TIMESTAMPTZ, INTERVAL, and arrays of any atomic type\n\
4518- NUMERIC precision > 18 requires .hyper file format version 3 (default in this MCP)\n\
4519- No SERIAL / BIGSERIAL / UUID / JSON / JSONB / geometry types\n\
4520\n\
4521SELECT / QUERY\n\
4522- LIMIT / OFFSET work as in PostgreSQL; TOP N is also accepted\n\
4523- LATERAL is optional: subqueries in FROM always see preceding FROM items implicitly\n\
4524- DISTINCT ON (expr, ...) is supported\n\
4525- FROM clause is optional (can evaluate expressions without a table)\n\
4526- Function calls may appear directly in the FROM list\n\
4527- information_schema and pg_catalog do NOT exist; use the describe/sample tools\n\
4528\n\
4529GROUP BY / AGGREGATION\n\
4530- GROUPING SETS, ROLLUP, CUBE all supported\n\
4531- GROUP BY DISTINCT removes duplicate grouping sets before processing\n\
4532- FILTER (WHERE ...) clause supported on aggregate calls\n\
4533- Ordered-set aggregates: MODE(), PERCENTILE_CONT(), PERCENTILE_DISC() with WITHIN GROUP (ORDER BY ...)\n\
4534- APPROX_COUNT_DISTINCT() for fast approximate cardinality\n\
4535- GROUPING() function identifies which columns are aggregated in GROUPING SETS\n\
4536\n\
4537WINDOW FUNCTIONS\n\
4538- Standard: row_number, rank, dense_rank, percent_rank, cume_dist, ntile, lag, lead,\n\
4539 first_value, last_value, nth_value\n\
4540- Hyper extension: modified_rank() — like rank() but assigns the LOWEST rank on ties\n\
4541- IGNORE NULLS / RESPECT NULLS supported on last_value only\n\
4542- nth_value supports FROM FIRST / FROM LAST\n\
4543- Frame modes: ROWS, RANGE, GROUPS; EXCLUDE CURRENT ROW / GROUP / TIES / NO OTHERS\n\
4544- Window-specific functions do NOT support DISTINCT or ORDER BY in their argument list\n\
4545\n\
4546SET-RETURNING FUNCTIONS (usable in FROM)\n\
4547- unnest(array) — expands an array to rows; supports WITH ORDINALITY\n\
4548- generate_series(start, stop [, step]) — numeric and datetime variants\n\
4549- external(path, format => '...') — reads Parquet, CSV, Iceberg etc. directly from files\n\
4550\n\
4551SET OPERATORS\n\
4552- UNION, INTERSECT, EXCEPT all supported; INTERSECT binds tighter than UNION/EXCEPT\n\
4553- ORDER BY and LIMIT/OFFSET can appear on parenthesized sub-expressions or the final result\n\
4554\n\
4555CTEs\n\
4556- WITH and WITH RECURSIVE both supported\n\
4557- CTEs evaluate once per query execution even if referenced multiple times\n\
4558\n\
4559IDENTIFIERS\n\
4560- Unquoted identifiers are folded to lowercase; double-quote to preserve case or use special chars\n\
4561- Quote names containing uppercase letters, digits at the start, or special characters\n\
4562\n\
4563NOT AVAILABLE IN HYPER (Data 360 / Data Cloud-only features)\n\
4564- AI functions: AI_CLASSIFY, AI_SENTIMENT, and other Data Cloud AI scalar functions\n\
4565- Data Cloud federation / streaming-specific functions\n\
4566\n\
4567Full SQL reference: https://developer.salesforce.com/docs/data/data-cloud-query-guide/references/dc-sql-reference";
4568
4569 let header = if self.read_only {
4570 "HyperDB MCP (read-only): SQL analytics for LLM workflows. Query existing tables, \
4571 sample data, export results. Mutating operations are disabled. \
4572 Call get_readme for a concise tool index, parameter rules, and usage examples."
4573 } else {
4574 "HyperDB MCP: instant SQL analytics for LLM workflows. Load data (CSV, JSON, Parquet, \
4575 Arrow IPC, Apache Iceberg), query with SQL, export results (Parquet, Iceberg, Arrow IPC, \
4576 CSV, Hyper). Use query for SELECT and execute for DDL/DML. \
4577 Call get_readme for a concise tool index, parameter rules, and usage examples."
4578 };
4579 let instructions = format!("{header}{sql_dialect}");
4580 let mut server_info = Implementation::default();
4581 server_info.name = "HyperDB".into();
4582 server_info.title = Some("HyperDB — Hyper SQL Analytics".into());
4583 server_info.version = env!("CARGO_PKG_VERSION").into();
4584 server_info.description = Some(
4585 "MCP server for Tableau Hyper: instant SQL analytics over \
4586 CSV, JSON, Parquet, Arrow IPC, and Apache Iceberg with schema inference, \
4587 partial schema overrides, full-file numeric widening, and \
4588 dry-run file inspection. SQL dialect is PostgreSQL-compatible with \
4589 extensions (Salesforce Data Cloud SQL). Full SQL reference: \
4590 https://developer.salesforce.com/docs/data/data-cloud-query-guide/references/dc-sql-reference/data-cloud-sql-context.html"
4591 .into(),
4592 );
4593
4594 let mut info = ServerInfo::default();
4595 info.instructions = Some(instructions);
4596 info.server_info = server_info;
4597 info.capabilities = ServerCapabilities::builder()
4598 .enable_tools()
4599 .enable_prompts()
4600 .enable_resources()
4601 .enable_resources_subscribe()
4606 .enable_resources_list_changed()
4607 .build();
4608 info
4609 }
4610
4611 async fn initialize(
4612 &self,
4613 request: InitializeRequestParams,
4614 context: RequestContext<RoleServer>,
4615 ) -> Result<InitializeResult, rmcp::ErrorData> {
4616 let name = &request.client_info.name;
4617 let version = &request.client_info.version;
4618 let label = if version.is_empty() {
4619 name.clone()
4620 } else {
4621 format!("{name} {version}")
4622 };
4623 if let Ok(mut guard) = self.client_name.lock() {
4624 *guard = Some(label);
4625 }
4626 context.peer.set_peer_info(request);
4627 Ok(self.get_info())
4628 }
4629
4630 async fn subscribe(
4639 &self,
4640 request: SubscribeRequestParams,
4641 context: RequestContext<RoleServer>,
4642 ) -> Result<(), rmcp::ErrorData> {
4643 self.subscriptions.subscribe(&request.uri, context.peer);
4644 Ok(())
4645 }
4646
4647 async fn unsubscribe(
4652 &self,
4653 request: UnsubscribeRequestParams,
4654 context: RequestContext<RoleServer>,
4655 ) -> Result<(), rmcp::ErrorData> {
4656 self.subscriptions.unsubscribe(&request.uri, &context.peer);
4657 Ok(())
4658 }
4659
4660 async fn list_resources(
4666 &self,
4667 _request: Option<PaginatedRequestParams>,
4668 _context: RequestContext<RoleServer>,
4669 ) -> Result<ListResourcesResult, rmcp::ErrorData> {
4670 let mut resources = vec![
4671 RawResource {
4672 uri: "hyper://workspace".into(),
4673 name: "Workspace Info".into(),
4674 title: Some("Hyper Workspace".into()),
4675 description: Some("Workspace mode, table count, total rows, disk usage".into()),
4676 mime_type: Some("application/json".into()),
4677 size: None,
4678 icons: None,
4679 meta: None,
4680 }
4681 .no_annotation(),
4682 RawResource {
4683 uri: "hyper://tables".into(),
4684 name: "All Tables".into(),
4685 title: Some("All Tables".into()),
4686 description: Some("List of all tables with column schemas and row counts".into()),
4687 mime_type: Some("application/json".into()),
4688 size: None,
4689 icons: None,
4690 meta: None,
4691 }
4692 .no_annotation(),
4693 RawResource {
4694 uri: "hyper://readme".into(),
4695 name: "Workspace Readme".into(),
4696 title: Some("HyperDB workspace readme".into()),
4697 description: Some(
4698 "Markdown overview of the workspace: tables, row counts, related \
4699 resources, and tool hints for LLMs orienting themselves."
4700 .into(),
4701 ),
4702 mime_type: Some("text/markdown".into()),
4703 size: None,
4704 icons: None,
4705 meta: None,
4706 }
4707 .no_annotation(),
4708 RawResource {
4709 uri: "hyper://schema/kv".into(),
4710 name: "KV store schema".into(),
4711 title: Some("Key-value scratchpad schema".into()),
4712 description: Some(
4713 "Schema of the _hyperdb_kv_store table backing the kv_* tools, the \
4714 ephemeral-vs-persistent durability rule, and the LEFT JOIN enrichment \
4715 pattern for joining KV metadata onto analytical tables."
4716 .into(),
4717 ),
4718 mime_type: Some("text/plain".into()),
4719 size: None,
4720 icons: None,
4721 meta: None,
4722 }
4723 .no_annotation(),
4724 ];
4725
4726 if let Ok(tables) = self.with_engine(super::engine::Engine::describe_tables) {
4727 for table in tables {
4731 if let Some(name) = table.get("name").and_then(|v| v.as_str()) {
4732 let row_count = table
4733 .get("row_count")
4734 .and_then(serde_json::Value::as_i64)
4735 .unwrap_or(0);
4736 resources.push(
4737 RawResource {
4738 uri: format!("hyper://tables/{name}/schema"),
4739 name: format!("Schema of {name}"),
4740 title: Some(format!("{name} schema")),
4741 description: Some(format!(
4742 "Column schema and row count ({row_count} rows) for table '{name}'"
4743 )),
4744 mime_type: Some("application/json".into()),
4745 size: None,
4746 icons: None,
4747 meta: None,
4748 }
4749 .no_annotation(),
4750 );
4751 resources.push(
4752 RawResource {
4753 uri: format!("hyper://tables/{name}/sample"),
4754 name: format!("Sample of {name}"),
4755 title: Some(format!("{name} sample (JSON)")),
4756 description: Some(format!(
4757 "First {TABLE_SAMPLE_ROWS} rows of '{name}' as JSON, with schema"
4758 )),
4759 mime_type: Some("application/json".into()),
4760 size: None,
4761 icons: None,
4762 meta: None,
4763 }
4764 .no_annotation(),
4765 );
4766 resources.push(
4767 RawResource {
4768 uri: format!("hyper://tables/{name}/csv-sample"),
4769 name: format!("CSV sample of {name}"),
4770 title: Some(format!("{name} sample (CSV)")),
4771 description: Some(format!(
4772 "First {TABLE_CSV_SAMPLE_ROWS} rows of '{name}' as CSV"
4773 )),
4774 mime_type: Some("text/csv".into()),
4775 size: None,
4776 icons: None,
4777 meta: None,
4778 }
4779 .no_annotation(),
4780 );
4781 }
4782 }
4783 }
4784
4785 let store = Arc::clone(&self.saved_queries);
4786 if let Ok(saved) = self.with_saved_query_store(|engine| store.list(engine)) {
4787 for q in saved {
4788 let desc = q
4789 .description
4790 .clone()
4791 .unwrap_or_else(|| format!("Saved read-only SQL query '{}'", q.name));
4792 resources.push(
4793 RawResource {
4794 uri: format!("hyper://queries/{}/definition", q.name),
4795 name: format!("Query: {}", q.name),
4796 title: Some(format!("{} (definition)", q.name)),
4797 description: Some(format!("SQL + metadata for saved query '{}'", q.name)),
4798 mime_type: Some("application/json".into()),
4799 size: None,
4800 icons: None,
4801 meta: None,
4802 }
4803 .no_annotation(),
4804 );
4805 resources.push(
4806 RawResource {
4807 uri: format!("hyper://queries/{}/result", q.name),
4808 name: format!("Result: {}", q.name),
4809 title: Some(format!("{} (result)", q.name)),
4810 description: Some(format!("{desc} — re-runs on every read")),
4811 mime_type: Some("application/json".into()),
4812 size: None,
4813 icons: None,
4814 meta: None,
4815 }
4816 .no_annotation(),
4817 );
4818 }
4819 }
4820
4821 Ok(ListResourcesResult {
4822 resources,
4823 next_cursor: None,
4824 meta: None,
4825 })
4826 }
4827
4828 async fn list_resource_templates(
4831 &self,
4832 _request: Option<PaginatedRequestParams>,
4833 _context: RequestContext<RoleServer>,
4834 ) -> Result<ListResourceTemplatesResult, rmcp::ErrorData> {
4835 let templates = vec![
4836 RawResourceTemplate {
4837 uri_template: "hyper://tables/{name}/schema".into(),
4838 name: "Table Schema".into(),
4839 title: Some("Table Schema".into()),
4840 description: Some(
4841 "Column schema, types, nullability, and row count for a named table".into(),
4842 ),
4843 mime_type: Some("application/json".into()),
4844 icons: None,
4845 }
4846 .no_annotation(),
4847 RawResourceTemplate {
4848 uri_template: "hyper://tables/{name}/sample".into(),
4849 name: "Table Sample (JSON)".into(),
4850 title: Some("Table Sample".into()),
4851 description: Some(
4852 "First few rows of a named table as JSON, with schema. For a \
4853 configurable row count use the `sample` tool instead."
4854 .into(),
4855 ),
4856 mime_type: Some("application/json".into()),
4857 icons: None,
4858 }
4859 .no_annotation(),
4860 RawResourceTemplate {
4861 uri_template: "hyper://tables/{name}/csv-sample".into(),
4862 name: "Table Sample (CSV)".into(),
4863 title: Some("Table Sample (CSV)".into()),
4864 description: Some(
4865 "First few rows of a named table as CSV, header-first, for \
4866 spreadsheet and Pandas consumers."
4867 .into(),
4868 ),
4869 mime_type: Some("text/csv".into()),
4870 icons: None,
4871 }
4872 .no_annotation(),
4873 RawResourceTemplate {
4874 uri_template: "hyper://queries/{name}/definition".into(),
4875 name: "Saved Query Definition".into(),
4876 title: Some("Saved Query Definition".into()),
4877 description: Some(
4878 "Stored SQL plus metadata (description, created_at) for a saved \
4879 query registered via the `save_query` tool."
4880 .into(),
4881 ),
4882 mime_type: Some("application/json".into()),
4883 icons: None,
4884 }
4885 .no_annotation(),
4886 RawResourceTemplate {
4887 uri_template: "hyper://queries/{name}/result".into(),
4888 name: "Saved Query Result".into(),
4889 title: Some("Saved Query Result".into()),
4890 description: Some(
4891 "Live result of a saved query. The stored SQL re-runs on every \
4892 resource read — no caching, always fresh."
4893 .into(),
4894 ),
4895 mime_type: Some("application/json".into()),
4896 icons: None,
4897 }
4898 .no_annotation(),
4899 ];
4900 Ok(ListResourceTemplatesResult {
4901 resource_templates: templates,
4902 next_cursor: None,
4903 meta: None,
4904 })
4905 }
4906
4907 async fn read_resource(
4912 &self,
4913 request: ReadResourceRequestParams,
4914 _context: RequestContext<RoleServer>,
4915 ) -> Result<ReadResourceResult, rmcp::ErrorData> {
4916 let uri = &request.uri;
4917 let (mime_type, text) = match self.resource_body_for_uri(uri) {
4918 Ok(Some(body)) => (body.mime_type().to_string(), body.to_text()),
4919 Ok(None) => {
4920 return Err(rmcp::ErrorData::invalid_params(
4921 format!("Unknown resource URI: {uri}"),
4922 None,
4923 ));
4924 }
4925 Err(e) => {
4926 let err_val = serde_json::to_value(&e).unwrap_or(Value::String(e.to_string()));
4929 let text =
4930 serde_json::to_string_pretty(&json!({ "error": err_val })).unwrap_or_default();
4931 ("application/json".into(), text)
4932 }
4933 };
4934
4935 Ok(ReadResourceResult::new(vec![
4936 ResourceContents::TextResourceContents {
4937 uri: uri.clone(),
4938 mime_type: Some(mime_type),
4939 text,
4940 meta: None,
4941 },
4942 ]))
4943 }
4944}
4945
4946fn validate_execute_batch(stmts: &[String]) -> Result<(), McpError> {
4970 use crate::engine::strip_leading_sql_comments;
4971
4972 if stmts.is_empty() {
4973 return Err(McpError::new(
4974 ErrorCode::InvalidArgument,
4975 "`sql` must be a non-empty array of SQL statements.",
4976 )
4977 .with_suggestion("Pass at least one statement: `sql: [\"INSERT INTO t VALUES (1)\"]`."));
4978 }
4979
4980 let mut has_schema_change = false;
4981 let mut has_data_mutation = false;
4982
4983 for (idx, stmt) in stmts.iter().enumerate() {
4984 if strip_leading_sql_comments(stmt).trim().is_empty() {
4985 return Err(McpError::new(
4986 ErrorCode::InvalidArgument,
4987 format!("`sql[{idx}]` is empty or contains only whitespace/comments."),
4988 )
4989 .with_suggestion("Remove the empty element or replace it with a real statement."));
4990 }
4991 match classify_statement(stmt) {
5000 StatementKind::ReadOnly => {
5001 return Err(McpError::new(
5002 ErrorCode::SqlError,
5003 format!(
5004 "`sql[{idx}]` is a read-only statement; the `execute` tool is for DDL/DML."
5005 ),
5006 )
5007 .with_suggestion(
5008 "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 …`).",
5009 ));
5010 }
5011 StatementKind::TransactionControl => {
5012 return Err(McpError::new(
5020 ErrorCode::InvalidArgument,
5021 format!(
5022 "`sql[{idx}]` is a transaction-control statement (BEGIN / COMMIT / ROLLBACK / SAVEPOINT); these are not allowed in `execute` batches."
5023 ),
5024 )
5025 .with_suggestion(
5026 "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.",
5027 ));
5028 }
5029 StatementKind::Ddl => has_schema_change = true,
5030 StatementKind::Dml => has_data_mutation = true,
5031 StatementKind::Other => {}
5032 }
5033 }
5034
5035 if has_schema_change && has_data_mutation {
5036 return Err(McpError::new(
5037 ErrorCode::InvalidArgument,
5038 "Cannot mix DDL (CREATE/DROP/ALTER/TRUNCATE/RENAME) with DML (INSERT/UPDATE/DELETE/COPY/MERGE) in one `execute` batch.",
5039 )
5040 .with_suggestion(
5041 "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.",
5042 ));
5043 }
5044
5045 if has_schema_change && stmts.len() > 1 {
5046 return Err(McpError::new(
5047 ErrorCode::InvalidArgument,
5048 "Multi-statement DDL batches are not supported.",
5049 )
5050 .with_suggestion(
5051 "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.",
5052 ));
5053 }
5054
5055 Ok(())
5056}
5057
5058fn value_to_csv_cell(v: &Value) -> String {
5064 match v {
5065 Value::Null => String::new(),
5066 Value::Bool(b) => b.to_string(),
5067 Value::Number(n) => n.to_string(),
5068 Value::String(s) => s.clone(),
5069 _ => v.to_string(),
5070 }
5071}
5072
5073fn detect_format(data: &str) -> String {
5076 let trimmed = data.trim_start();
5077 if trimmed.starts_with('[') || trimmed.starts_with('{') {
5078 "json".into()
5079 } else {
5080 "csv".into()
5081 }
5082}
5083
5084fn rand_suffix() -> String {
5088 use std::time::{SystemTime, UNIX_EPOCH};
5089 let t = SystemTime::now()
5090 .duration_since(UNIX_EPOCH)
5091 .unwrap_or_default();
5092 format!("{}", t.as_nanos() % 1_000_000_000)
5093}
5094
5095fn qualified_name(engine: &Engine, db: Option<&str>, table: &str) -> String {
5106 let alias = db.map_or_else(|| engine.primary_db_name(), str::to_string);
5107 let escaped_alias = alias.replace('"', "\"\"");
5108 let escaped_table = table.replace('"', "\"\"");
5109 format!("\"{escaped_alias}\".\"public\".\"{escaped_table}\"")
5110}
5111
5112fn target_exists(engine: &Engine, db: Option<&str>, table: &str) -> Result<bool, McpError> {
5117 let sql = format!(
5118 "SELECT 1 FROM {} LIMIT 0",
5119 qualified_name(engine, db, table)
5120 );
5121 match engine.execute_query_to_json(&sql) {
5122 Ok(_) => Ok(true),
5123 Err(e) => {
5124 let m = e.message.to_lowercase();
5125 let missing = m.contains("does not exist")
5126 || m.contains("undefined table")
5127 || e.message.contains("42P01");
5128 if missing {
5129 Ok(false)
5130 } else {
5131 Err(e)
5132 }
5133 }
5134 }
5135}
5136
5137fn count_rows(engine: &Engine, db: Option<&str>, table: &str) -> i64 {
5142 let sql = format!(
5143 "SELECT COUNT(*) AS cnt FROM {}",
5144 qualified_name(engine, db, table)
5145 );
5146 engine
5147 .execute_query_to_json(&sql)
5148 .ok()
5149 .and_then(|rows| {
5150 rows.first()
5151 .and_then(|r| r.get("cnt").and_then(serde_json::Value::as_i64))
5152 })
5153 .unwrap_or(0)
5154}
5155
5156fn probe_table_count(engine: &Engine, alias: &str) -> Value {
5160 let escaped_alias = alias.replace('"', "\"\"");
5161 let sql = format!(
5162 "SELECT COUNT(*) AS cnt FROM \"{escaped_alias}\".pg_catalog.pg_tables WHERE schemaname = 'public'"
5163 );
5164 match engine.execute_query_to_json(&sql) {
5165 Ok(rows) => rows
5166 .first()
5167 .and_then(|r| r.get("cnt").and_then(serde_json::Value::as_i64))
5168 .map_or(Value::Null, |n| json!(n)),
5169 Err(_) => Value::Null,
5170 }
5171}
5172
5173fn prepare_temp_attachments(
5177 specs: &[AttachSpec],
5178 read_only: bool,
5179) -> Result<Vec<AttachRequest>, McpError> {
5180 let mut out = Vec::with_capacity(specs.len());
5181 for spec in specs {
5182 let writable = spec.writable.unwrap_or(false);
5183 if writable && read_only {
5184 return Err(McpError::new(
5185 ErrorCode::ReadOnlyViolation,
5186 format!(
5187 "temp_attach for alias '{}' requested writable:true but the server is --read-only",
5188 spec.alias
5189 ),
5190 ));
5191 }
5192 let on_missing = attach::OnMissing::parse(spec.on_missing.as_deref())?;
5193 if on_missing == attach::OnMissing::Create && !writable {
5194 return Err(McpError::new(
5195 ErrorCode::InvalidArgument,
5196 format!(
5197 "temp_attach alias '{}' has on_missing='create' but writable is not true — \
5198 an empty .hyper file that cannot be written to cannot be populated.",
5199 spec.alias
5200 ),
5201 ));
5202 }
5203 let source = match spec.kind.as_str() {
5204 "local_file" => {
5205 let Some(raw) = spec.path.as_deref() else {
5206 return Err(McpError::new(
5207 ErrorCode::InvalidArgument,
5208 format!("temp_attach alias '{}' requires a 'path'", spec.alias),
5209 ));
5210 };
5211 let resolved = match on_missing {
5212 attach::OnMissing::Error => attach::validate_local_path(raw)?,
5213 attach::OnMissing::Create => attach::validate_local_path_for_create(raw)?,
5214 };
5215 AttachSource::LocalFile { path: resolved }
5216 }
5217 other => {
5218 return Err(McpError::new(
5219 ErrorCode::InvalidArgument,
5220 format!(
5221 "Unsupported temp_attach kind '{other}' for alias '{}'. Only 'local_file' is supported today.",
5222 spec.alias
5223 ),
5224 ));
5225 }
5226 };
5227 attach::validate_alias(&spec.alias)?;
5228 out.push(AttachRequest {
5229 alias: spec.alias.clone(),
5230 source,
5231 writable,
5232 on_missing,
5233 });
5234 }
5235 Ok(out)
5236}
5237
5238fn perform_copy(
5243 engine: &Engine,
5244 mode: &str,
5245 target_db: Option<&str>,
5246 target_table: &str,
5247 sql_body: &str,
5248) -> Result<Value, McpError> {
5249 let qualified = qualified_name(engine, target_db, target_table);
5250 let exists = target_exists(engine, target_db, target_table)?;
5251 let timer = crate::stats::StatsTimer::start();
5252
5253 match mode {
5254 "create" => {
5255 if exists {
5256 return Err(McpError::new(
5257 ErrorCode::InvalidArgument,
5258 format!(
5259 "Target '{target_table}' already exists. Use mode='append' to add rows or mode='replace' to drop and recreate."
5260 ),
5261 ));
5262 }
5263 engine.execute_command(&format!("CREATE TABLE {qualified} AS {sql_body}"))?;
5264 }
5265 "append" => {
5266 if !exists {
5267 return Err(McpError::new(
5268 ErrorCode::InvalidArgument,
5269 format!(
5270 "Target '{target_table}' does not exist. Use mode='create' to create it from the query or mode='replace' to drop and recreate."
5271 ),
5272 ));
5273 }
5274 engine.execute_command(&format!("INSERT INTO {qualified} {sql_body}"))?;
5275 }
5276 "replace" => {
5277 engine.execute_command(&format!("DROP TABLE IF EXISTS {qualified}"))?;
5286 engine.execute_command(&format!("CREATE TABLE {qualified} AS {sql_body}"))?;
5287 }
5288 other => {
5289 return Err(McpError::new(
5290 ErrorCode::InvalidArgument,
5291 format!("copy_query mode '{other}' is not supported"),
5292 ));
5293 }
5294 }
5295
5296 let elapsed_ms = timer.elapsed_ms();
5297 let row_count = count_rows(engine, target_db, target_table);
5298 Ok(json!({
5299 "target_table": target_table,
5300 "target_database": target_db.unwrap_or(LOCAL_ALIAS),
5301 "mode": mode,
5302 "row_count": row_count,
5303 "stats": { "operation": "copy_query", "elapsed_ms": elapsed_ms },
5304 }))
5305}
5306
5307#[cfg(test)]
5308mod validate_execute_batch_tests {
5309 use super::*;
5310
5311 fn s(v: &[&str]) -> Vec<String> {
5312 v.iter().map(|x| (*x).to_string()).collect()
5313 }
5314
5315 #[test]
5316 fn rejects_empty_array() {
5317 let err = validate_execute_batch(&[]).unwrap_err();
5318 assert_eq!(err.code, ErrorCode::InvalidArgument);
5319 }
5320
5321 #[test]
5322 fn rejects_whitespace_only_element() {
5323 let err = validate_execute_batch(&s(&[" "])).unwrap_err();
5324 assert_eq!(err.code, ErrorCode::InvalidArgument);
5325 assert!(err.message.contains("sql[0]"));
5326 let err = validate_execute_batch(&s(&["INSERT INTO t VALUES (1)", "/* */"])).unwrap_err();
5327 assert!(err.message.contains("sql[1]"));
5328 }
5329
5330 #[test]
5331 fn rejects_read_only_element() {
5332 let err =
5333 validate_execute_batch(&s(&["INSERT INTO t VALUES (1)", "SELECT 1"])).unwrap_err();
5334 assert_eq!(err.code, ErrorCode::SqlError);
5335 assert!(err.message.contains("sql[1]"));
5336 }
5337
5338 #[test]
5339 fn rejects_transaction_control_in_batch() {
5340 for sql in [
5343 "BEGIN",
5344 "COMMIT",
5345 "ROLLBACK",
5346 "SAVEPOINT sp1",
5347 "START TRANSACTION",
5348 "END",
5349 "RELEASE SAVEPOINT sp1",
5350 ] {
5351 let err = validate_execute_batch(&s(&["INSERT INTO t VALUES (1)", sql])).unwrap_err();
5352 assert_eq!(err.code, ErrorCode::InvalidArgument, "for `{sql}`");
5353 assert!(
5354 err.message.contains("transaction-control"),
5355 "for `{sql}`: {}",
5356 err.message
5357 );
5358 }
5359 }
5360
5361 #[test]
5362 fn rejects_transaction_control_singleton() {
5363 let err = validate_execute_batch(&s(&["BEGIN"])).unwrap_err();
5368 assert_eq!(err.code, ErrorCode::InvalidArgument);
5369 }
5370
5371 #[test]
5372 fn rejects_ddl_dml_mix() {
5373 let err =
5374 validate_execute_batch(&s(&["CREATE TABLE x (i INT)", "INSERT INTO x VALUES (1)"]))
5375 .unwrap_err();
5376 assert_eq!(err.code, ErrorCode::InvalidArgument);
5377 assert!(err.message.contains("DDL"));
5378 }
5379
5380 #[test]
5381 fn rejects_multi_ddl() {
5382 let err = validate_execute_batch(&s(&["CREATE TABLE a (i INT)", "CREATE TABLE b (j INT)"]))
5383 .unwrap_err();
5384 assert_eq!(err.code, ErrorCode::InvalidArgument);
5385 assert!(err.message.contains("Multi-statement DDL"));
5386 }
5387
5388 #[test]
5389 fn allows_single_ddl() {
5390 validate_execute_batch(&s(&["CREATE TABLE a (i INT)"])).unwrap();
5391 }
5392
5393 #[test]
5394 fn allows_multi_dml() {
5395 validate_execute_batch(&s(&[
5396 "UPDATE settings SET value = 'x' WHERE key = 'k'",
5397 "INSERT INTO settings (key, value) SELECT 'k', 'x' WHERE NOT EXISTS (SELECT 1 FROM settings WHERE key = 'k')",
5398 ]))
5399 .unwrap();
5400 }
5401
5402 #[test]
5403 fn allows_other_kinds() {
5404 validate_execute_batch(&s(&["SET schema_search_path = 'mydb'"])).unwrap();
5408 }
5409
5410 #[test]
5411 fn allows_trailing_semicolon() {
5412 validate_execute_batch(&s(&["INSERT INTO t VALUES (1);"])).unwrap();
5413 }
5414}
5415
5416#[cfg(test)]
5417mod kv_value_path_size_tests {
5418 use super::*;
5419
5420 #[test]
5421 fn accepts_file_at_or_below_cap() {
5422 HyperMcpServer::check_value_path_size(0).unwrap();
5424 HyperMcpServer::check_value_path_size(1024).unwrap();
5425 HyperMcpServer::check_value_path_size(HyperMcpServer::KV_VALUE_PATH_MAX_BYTES).unwrap();
5426 }
5427
5428 #[test]
5429 fn rejects_file_above_cap() {
5430 let err =
5431 HyperMcpServer::check_value_path_size(HyperMcpServer::KV_VALUE_PATH_MAX_BYTES + 1)
5432 .unwrap_err();
5433 assert_eq!(err.code, ErrorCode::InvalidArgument);
5434 assert!(
5435 err.message.contains("hard limit"),
5436 "message should name the hard limit: {}",
5437 err.message
5438 );
5439 }
5440}