use crate::engine::Engine;
use crate::error::{ErrorCode, McpError};
use hyperdb_api::escape_sql_path;
use serde_json::{json, Value};
use std::path::PathBuf;
use std::sync::Mutex;
use std::time::SystemTime;
pub const LOCAL_ALIAS: &str = "local";
fn sql_string_literal(s: &str) -> String {
format!("'{}'", s.replace('\'', "''"))
}
fn set_primary_search_path(engine: &Engine) -> Result<(), McpError> {
let sql = format!(
"SET schema_search_path = {}",
sql_string_literal(&engine.primary_db_name()),
);
engine.execute_command(&sql)?;
Ok(())
}
fn reset_search_path(engine: &Engine) -> Result<(), McpError> {
if engine.has_persistent() {
set_primary_search_path(engine)
} else {
engine.execute_command("RESET schema_search_path")?;
Ok(())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum OnMissing {
#[default]
Error,
Create,
}
impl OnMissing {
pub fn parse(value: Option<&str>) -> Result<Self, McpError> {
match value.map(str::trim) {
None | Some("" | "error") => Ok(Self::Error),
Some("create") => Ok(Self::Create),
Some(other) => Err(McpError::new(
ErrorCode::InvalidArgument,
format!("on_missing must be 'error' or 'create', got '{other}'"),
)),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AttachSource {
LocalFile {
path: PathBuf,
},
}
impl AttachSource {
#[must_use]
pub fn kind_str(&self) -> &'static str {
match self {
Self::LocalFile { .. } => "local_file",
}
}
#[must_use]
pub fn to_json(&self) -> Value {
match self {
Self::LocalFile { path } => json!({
"kind": "local_file",
"path": path.to_string_lossy(),
}),
}
}
}
#[derive(Debug, Clone)]
pub struct AttachedDb {
pub alias: String,
pub source: AttachSource,
pub writable: bool,
pub attached_at: SystemTime,
}
impl AttachedDb {
#[must_use]
pub fn to_json(&self) -> Value {
let attached_at = chrono::DateTime::<chrono::Utc>::from(self.attached_at).to_rfc3339();
json!({
"alias": self.alias,
"source": self.source.to_json(),
"kind": self.source.kind_str(),
"writable": self.writable,
"attached_at": attached_at,
})
}
}
#[derive(Debug, Clone)]
pub struct AttachRequest {
pub alias: String,
pub source: AttachSource,
pub writable: bool,
pub on_missing: OnMissing,
}
#[derive(Debug)]
pub struct AttachRegistry {
inner: Mutex<Vec<AttachedDb>>,
}
impl Default for AttachRegistry {
fn default() -> Self {
Self::new()
}
}
impl AttachRegistry {
#[must_use]
pub fn new() -> Self {
Self {
inner: Mutex::new(Vec::new()),
}
}
pub fn attach(&self, engine: &Engine, mut req: AttachRequest) -> Result<AttachedDb, McpError> {
validate_alias(&req.alias)?;
req.alias = req.alias.to_ascii_lowercase();
let mut guard = self.lock()?;
if guard.iter().any(|a| a.alias == req.alias) {
return Err(McpError::new(
ErrorCode::InvalidArgument,
format!(
"Alias '{}' is already in use. Detach it first or pick a different alias.",
req.alias
),
));
}
let mut file_was_created = false;
let sql = match &req.source {
AttachSource::LocalFile { path } => {
if !path.exists() {
match req.on_missing {
OnMissing::Error => {
return Err(McpError::new(
ErrorCode::FileNotFound,
format!(
"Attach path does not exist: {}. \
Pass on_missing='create' (with writable:true) \
to create an empty .hyper file at that path.",
path.display()
),
));
}
OnMissing::Create => {
if !req.writable {
return Err(McpError::new(
ErrorCode::InvalidArgument,
"on_missing='create' requires writable:true — \
an empty .hyper file that cannot be written to \
cannot be populated.",
));
}
let create_sql = format!(
"CREATE DATABASE IF NOT EXISTS {}",
escape_sql_path(&path.to_string_lossy()),
);
engine.execute_command(&create_sql)?;
file_was_created = true;
}
}
}
format!(
"ATTACH DATABASE {path} AS \"{alias}\"",
path = escape_sql_path(&path.to_string_lossy()),
alias = req.alias.replace('"', "\"\""),
)
}
};
engine.execute_command(&sql)?;
if let Err(e) = set_primary_search_path(engine) {
let detach_sql = format!("DETACH DATABASE \"{}\"", req.alias.replace('"', "\"\""));
if let Err(de) = engine.execute_command(&detach_sql) {
tracing::warn!(
alias = %req.alias,
err = %de.message,
"rollback DETACH after schema_search_path failure also failed; \
connection is in an inconsistent state — reconnect will clear it",
);
}
return Err(e);
}
if file_was_created {
if let Err(e) = crate::table_catalog::ensure_exists_in(engine, Some(&req.alias)) {
let detach_sql = format!("DETACH DATABASE \"{}\"", req.alias.replace('"', "\"\""));
if let Err(de) = engine.execute_command(&detach_sql) {
tracing::warn!(
alias = %req.alias,
err = %de.message,
"rollback DETACH after _table_catalog seed failure also failed; \
alias may remain attached until reconnect",
);
}
if guard.is_empty() {
let _ = reset_search_path(engine);
}
return Err(e);
}
}
let entry = AttachedDb {
alias: req.alias,
source: req.source,
writable: req.writable,
attached_at: SystemTime::now(),
};
guard.push(entry.clone());
Ok(entry)
}
pub fn detach(&self, engine: &Engine, alias: &str) -> Result<bool, McpError> {
let alias = alias.to_ascii_lowercase();
let mut guard = self.lock()?;
let pos = guard.iter().position(|a| a.alias == alias);
let Some(pos) = pos else {
return Ok(false);
};
let sql = format!("DETACH DATABASE \"{}\"", alias.replace('"', "\"\""));
engine.execute_command(&sql)?;
guard.remove(pos);
if guard.is_empty() {
if let Err(e) = reset_search_path(engine) {
tracing::warn!(
err = %e.message,
"detach succeeded but could not reset schema_search_path; \
unqualified queries should still work against the primary",
);
}
}
Ok(true)
}
pub fn list(&self) -> Vec<AttachedDb> {
self.lock().map(|g| g.clone()).unwrap_or_default()
}
pub fn get(&self, alias: &str) -> Option<AttachedDb> {
let alias = alias.to_ascii_lowercase();
self.lock()
.ok()
.and_then(|g| g.iter().find(|a| a.alias == alias).cloned())
}
pub fn replay_all(&self, engine: &Engine) -> Result<(), McpError> {
let mut guard = self.lock()?;
let snapshot = guard.clone();
guard.clear();
for entry in snapshot {
let sql = match &entry.source {
AttachSource::LocalFile { path } => format!(
"ATTACH DATABASE {path} AS \"{alias}\"",
path = escape_sql_path(&path.to_string_lossy()),
alias = entry.alias.replace('"', "\"\""),
),
};
match engine.execute_command(&sql) {
Ok(_) => guard.push(entry),
Err(e) => {
tracing::warn!(
alias = %entry.alias,
err = %e.message,
"dropping attachment that failed to replay after reconnect",
);
}
}
}
if !guard.is_empty() {
if let Err(e) = set_primary_search_path(engine) {
tracing::warn!(
err = %e.message,
"replay_all: could not re-pin schema_search_path after reconnect",
);
}
}
Ok(())
}
fn lock(&self) -> Result<std::sync::MutexGuard<'_, Vec<AttachedDb>>, McpError> {
self.inner
.lock()
.map_err(|_| McpError::new(ErrorCode::InternalError, "AttachRegistry lock poisoned"))
}
}
pub fn validate_alias(alias: &str) -> Result<(), McpError> {
if alias.eq_ignore_ascii_case(LOCAL_ALIAS) {
return Err(McpError::new(
ErrorCode::InvalidArgument,
format!(
"'{LOCAL_ALIAS}' is reserved for the primary workspace and cannot be used as an attach alias."
),
));
}
if alias.is_empty() || alias.len() > 63 {
return Err(McpError::new(
ErrorCode::InvalidArgument,
"Alias must be 1..=63 characters",
));
}
let mut chars = alias.chars();
let first = chars.next().unwrap();
if !(first.is_ascii_alphabetic() || first == '_') {
return Err(McpError::new(
ErrorCode::InvalidArgument,
format!("Alias '{alias}' must start with a letter or underscore"),
));
}
for c in chars {
if !(c.is_ascii_alphanumeric() || c == '_') {
return Err(McpError::new(
ErrorCode::InvalidArgument,
format!(
"Alias '{alias}' contains invalid character '{c}'. \
Allowed: [A-Za-z_][A-Za-z0-9_]{{0,62}}"
),
));
}
}
Ok(())
}
pub fn validate_local_path_for_create(path: &str) -> Result<PathBuf, McpError> {
let pb = PathBuf::from(path);
if !pb.is_absolute() {
return Err(McpError::new(
ErrorCode::InvalidArgument,
format!(
"Attach path '{path}' must be absolute. \
Pass a full path to a .hyper file."
),
));
}
if pb.exists() {
return validate_local_path(path);
}
let parent = pb.parent().ok_or_else(|| {
McpError::new(
ErrorCode::InvalidArgument,
format!("Attach path '{path}' has no parent directory"),
)
})?;
let file_name = pb.file_name().ok_or_else(|| {
McpError::new(
ErrorCode::InvalidArgument,
format!("Attach path '{path}' has no file-name component"),
)
})?;
let canonical_parent = std::fs::canonicalize(parent).map_err(|e| {
McpError::new(
ErrorCode::FileNotFound,
format!(
"Parent directory of attach path '{path}' does not exist: {e}. \
Create the directory first or use on_missing='error'."
),
)
})?;
if canonical_parent
.components()
.any(|c| matches!(c, std::path::Component::ParentDir))
{
return Err(McpError::new(
ErrorCode::InvalidArgument,
format!("Attach path '{path}' resolves to a location containing '..' components"),
));
}
Ok(canonical_parent.join(file_name))
}
pub fn validate_input_path(path: &str, kind: &str) -> Result<PathBuf, McpError> {
let pb = PathBuf::from(path);
if !pb.is_absolute() {
return Err(McpError::new(
ErrorCode::InvalidArgument,
format!("{kind} path '{path}' must be absolute"),
));
}
let canonical = std::fs::canonicalize(&pb).map_err(|e| {
McpError::new(
ErrorCode::FileNotFound,
format!("Cannot resolve {kind} path '{path}': {e}"),
)
})?;
if canonical
.components()
.any(|c| matches!(c, std::path::Component::ParentDir))
{
return Err(McpError::new(
ErrorCode::InvalidArgument,
format!("{kind} path '{path}' resolves to a location containing '..' components"),
));
}
Ok(canonical)
}
pub fn validate_output_path(path: &str, kind: &str) -> Result<PathBuf, McpError> {
let pb = PathBuf::from(path);
if !pb.is_absolute() {
return Err(McpError::new(
ErrorCode::InvalidArgument,
format!("{kind} path '{path}' must be absolute"),
));
}
if pb.exists() {
return validate_input_path(path, kind);
}
let parent = pb.parent().ok_or_else(|| {
McpError::new(
ErrorCode::InvalidArgument,
format!("{kind} path '{path}' has no parent directory"),
)
})?;
let file_name = pb.file_name().ok_or_else(|| {
McpError::new(
ErrorCode::InvalidArgument,
format!("{kind} path '{path}' has no file-name component"),
)
})?;
if !parent.exists() {
std::fs::create_dir_all(parent).map_err(|e| {
McpError::new(
ErrorCode::InternalError,
format!("Failed to create parent directory for {kind} path '{path}': {e}"),
)
})?;
}
let canonical_parent = std::fs::canonicalize(parent).map_err(|e| {
McpError::new(
ErrorCode::FileNotFound,
format!("Parent directory of {kind} path '{path}' does not exist: {e}"),
)
})?;
if canonical_parent
.components()
.any(|c| matches!(c, std::path::Component::ParentDir))
{
return Err(McpError::new(
ErrorCode::InvalidArgument,
format!("{kind} path '{path}' resolves to a location containing '..' components"),
));
}
Ok(canonical_parent.join(file_name))
}
pub fn validate_local_path(path: &str) -> Result<PathBuf, McpError> {
let pb = PathBuf::from(path);
if !pb.is_absolute() {
return Err(McpError::new(
ErrorCode::InvalidArgument,
format!(
"Attach path '{path}' must be absolute. \
Pass a full path to a local .hyper file."
),
));
}
let canonical = std::fs::canonicalize(&pb).map_err(|e| {
McpError::new(
ErrorCode::FileNotFound,
format!("Cannot resolve attach path '{path}': {e}"),
)
})?;
if canonical
.components()
.any(|c| matches!(c, std::path::Component::ParentDir))
{
return Err(McpError::new(
ErrorCode::InvalidArgument,
format!("Attach path '{path}' resolves to a location containing '..' components"),
));
}
Ok(canonical)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn alias_accepts_valid_identifiers() {
for a in ["src", "_scratch", "data_2024", "A", "alpha_beta_1"] {
validate_alias(a).unwrap_or_else(|e| panic!("expected {a:?} to be accepted: {e}"));
}
}
#[test]
fn alias_rejects_reserved_local() {
assert!(matches!(
validate_alias("local").unwrap_err().code,
ErrorCode::InvalidArgument
));
assert!(matches!(
validate_alias("LOCAL").unwrap_err().code,
ErrorCode::InvalidArgument
));
}
#[test]
fn alias_rejects_bad_shapes() {
for a in [
"",
"1abc",
"has space",
"a-b",
"a.b",
"a\"b",
&"a".repeat(64),
] {
let err = validate_alias(a).expect_err(&format!("expected {a:?} to be rejected"));
assert_eq!(err.code, ErrorCode::InvalidArgument, "alias={a:?}");
}
}
#[test]
fn path_rejects_relative() {
let err = validate_local_path("relative/path.hyper").unwrap_err();
assert_eq!(err.code, ErrorCode::InvalidArgument);
}
#[test]
fn path_rejects_missing() {
let missing = std::env::temp_dir().join("hyper_mcp_definitely_missing_99999.hyper");
let err = validate_local_path(missing.to_str().unwrap()).unwrap_err();
assert_eq!(err.code, ErrorCode::FileNotFound);
}
#[test]
fn path_canonicalizes_existing_file() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("sample.hyper");
std::fs::write(&file, b"").unwrap();
let noisy = dir.path().join(".").join("sample.hyper");
let resolved = validate_local_path(noisy.to_str().unwrap()).unwrap();
assert_eq!(resolved, std::fs::canonicalize(&file).unwrap());
}
#[test]
fn attached_db_to_json_round_trip() {
let entry = AttachedDb {
alias: "src".into(),
source: AttachSource::LocalFile {
path: PathBuf::from("/tmp/foo.hyper"),
},
writable: false,
attached_at: SystemTime::UNIX_EPOCH,
};
let j = entry.to_json();
assert_eq!(j["alias"], "src");
assert_eq!(j["writable"], false);
assert_eq!(j["kind"], "local_file");
assert_eq!(j["source"]["kind"], "local_file");
assert_eq!(j["source"]["path"], "/tmp/foo.hyper");
}
#[test]
fn validate_input_path_rejects_relative() {
let err = validate_input_path("relative/path.csv", "data file").unwrap_err();
assert_eq!(err.code, ErrorCode::InvalidArgument);
assert!(err.message.contains("data file"));
}
#[test]
fn validate_input_path_rejects_missing() {
let missing = std::env::temp_dir().join("hyper_mcp_validate_input_missing_99999.csv");
let err = validate_input_path(missing.to_str().unwrap(), "data file").unwrap_err();
assert_eq!(err.code, ErrorCode::FileNotFound);
}
#[test]
fn validate_input_path_accepts_existing_file() {
let f = tempfile::NamedTempFile::new().unwrap();
let canonical = validate_input_path(f.path().to_str().unwrap(), "data file").unwrap();
assert!(canonical.is_absolute());
}
#[test]
fn validate_input_path_kind_appears_in_error() {
let err = validate_input_path("relative.csv", "iceberg table").unwrap_err();
assert!(
err.message.contains("iceberg table"),
"got: {}",
err.message
);
}
#[test]
fn validate_output_path_rejects_relative() {
let err = validate_output_path("relative/out.csv", "export").unwrap_err();
assert_eq!(err.code, ErrorCode::InvalidArgument);
}
#[test]
fn validate_output_path_accepts_nonexistent_with_existing_parent() {
let dir = tempfile::tempdir().unwrap();
let target = dir.path().join("does-not-exist-yet.csv");
let canonical =
validate_output_path(target.to_str().unwrap(), "export").expect("should accept");
assert!(canonical.is_absolute());
assert_eq!(canonical.file_name(), target.file_name());
}
#[test]
fn validate_output_path_creates_missing_parent() {
let parent = std::env::temp_dir().join("hyper_mcp_validate_output_missing_parent_99999");
let _ = std::fs::remove_dir_all(&parent);
assert!(!parent.exists());
let target = parent.join("out.csv");
let canonical =
validate_output_path(target.to_str().unwrap(), "export").expect("should create parent");
assert!(canonical.is_absolute());
assert!(parent.exists(), "parent directory should have been created");
let _ = std::fs::remove_dir_all(&parent);
}
#[test]
fn validate_output_path_accepts_existing_file() {
let f = tempfile::NamedTempFile::new().unwrap();
let canonical = validate_output_path(f.path().to_str().unwrap(), "export").unwrap();
assert!(canonical.is_absolute());
}
}