use rmcp::model::{CallToolRequestParams, CallToolResult, ClientInfo};
use rmcp::service::{RoleClient, RunningService};
use rmcp::{ClientHandler, ServiceExt};
use std::path::PathBuf;
use std::sync::Arc;
use tempfile::TempDir;
use hyperdb_mcp::server::HyperMcpServer;
type TestResult = Result<(), Box<dyn std::error::Error + Send + Sync>>;
#[derive(Debug, Clone)]
struct DummyClientHandler;
impl ClientHandler for DummyClientHandler {
fn get_info(&self) -> ClientInfo {
ClientInfo::default()
}
}
struct TestHarness {
client: RunningService<RoleClient, DummyClientHandler>,
server_handle: tokio::task::JoinHandle<Result<(), Box<dyn std::error::Error + Send + Sync>>>,
_temp_dir: Arc<TempDir>,
}
impl TestHarness {
async fn start(
read_only: bool,
ephemeral_only: bool,
) -> Result<Self, Box<dyn std::error::Error + Send + Sync>> {
let temp_dir = Arc::new(TempDir::new()?);
let persistent_path = temp_dir.path().join("workspace.hyper");
Self::start_at(read_only, ephemeral_only, persistent_path, temp_dir).await
}
async fn start_at(
read_only: bool,
ephemeral_only: bool,
persistent_path: PathBuf,
temp_dir: Arc<TempDir>,
) -> Result<Self, Box<dyn std::error::Error + Send + Sync>> {
let (server_io, client_io) = tokio::io::duplex(64 * 1024);
let workspace = if ephemeral_only {
None
} else {
Some(persistent_path.to_string_lossy().to_string())
};
let server = HyperMcpServer::with_no_daemon(workspace, read_only, true);
let server_handle = tokio::spawn(async move {
let running = server
.serve(server_io)
.await
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> { Box::new(e) })?;
running
.waiting()
.await
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> { Box::new(e) })?;
Ok(())
});
let client = DummyClientHandler
.serve(client_io)
.await
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> { Box::new(e) })?;
Ok(Self {
client,
server_handle,
_temp_dir: temp_dir,
})
}
async fn shutdown(self) -> TestResult {
self.client
.cancel()
.await
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> { Box::new(e) })?;
self.server_handle.await??;
Ok(())
}
}
async fn call_tool(
client: &RunningService<RoleClient, DummyClientHandler>,
name: &'static str,
args: serde_json::Value,
) -> Result<CallToolResult, Box<dyn std::error::Error + Send + Sync>> {
let arguments = args.as_object().cloned();
let params = match arguments {
Some(args) => CallToolRequestParams::new(name).with_arguments(args),
None => CallToolRequestParams::new(name),
};
let result = client
.call_tool(params)
.await
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> { Box::new(e) })?;
Ok(result)
}
fn first_text(result: &CallToolResult) -> Option<String> {
result
.content
.first()
.and_then(|c| c.raw.as_text())
.map(|t| t.text.clone())
}
fn is_error(result: &CallToolResult) -> bool {
result.is_error.unwrap_or(false)
}
fn structured(result: &CallToolResult) -> serde_json::Value {
let text = first_text(result).unwrap_or_default();
serde_json::from_str(&text).unwrap_or(serde_json::Value::Null)
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn kv_set_get_roundtrip_and_overwrite() -> TestResult {
let h = TestHarness::start(false, false).await?;
let miss = call_tool(
&h.client,
"kv_get",
serde_json::json!({ "store": "cfg", "key": "theme" }),
)
.await?;
assert!(
!is_error(&miss),
"kv_get miss must not error: {:?}",
first_text(&miss)
);
assert_eq!(structured(&miss)["found"], serde_json::json!(false));
assert_eq!(structured(&miss)["value"], serde_json::Value::Null);
let set = call_tool(
&h.client,
"kv_set",
serde_json::json!({ "store": "cfg", "key": "theme", "value": "dark" }),
)
.await?;
assert!(!is_error(&set), "kv_set failed: {:?}", first_text(&set));
assert_eq!(structured(&set)["stored"], serde_json::json!(true));
let got = call_tool(
&h.client,
"kv_get",
serde_json::json!({ "store": "cfg", "key": "theme" }),
)
.await?;
assert_eq!(structured(&got)["found"], serde_json::json!(true));
assert_eq!(structured(&got)["value"], serde_json::json!("dark"));
call_tool(
&h.client,
"kv_set",
serde_json::json!({ "store": "cfg", "key": "theme", "value": "light" }),
)
.await?;
let got2 = call_tool(
&h.client,
"kv_get",
serde_json::json!({ "store": "cfg", "key": "theme" }),
)
.await?;
assert_eq!(structured(&got2)["value"], serde_json::json!("light"));
h.shutdown().await
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn kv_list_size_and_list_stores() -> TestResult {
let h = TestHarness::start(false, false).await?;
for (k, v) in [("gamma", "3"), ("alpha", "1"), ("beta", "2")] {
call_tool(
&h.client,
"kv_set",
serde_json::json!({ "store": "s", "key": k, "value": v }),
)
.await?;
}
let list = call_tool(&h.client, "kv_list", serde_json::json!({ "store": "s" })).await?;
assert_eq!(structured(&list)["count"], serde_json::json!(3));
assert_eq!(
structured(&list)["keys"],
serde_json::json!(["alpha", "beta", "gamma"]),
"keys must be sorted ascending"
);
let size = call_tool(&h.client, "kv_size", serde_json::json!({ "store": "s" })).await?;
assert_eq!(structured(&size)["size"], serde_json::json!(3));
let stores = call_tool(&h.client, "kv_list_stores", serde_json::json!({})).await?;
let names = structured(&stores)["stores"].clone();
assert!(
names.as_array().is_some_and(|a| a.iter().any(|n| n == "s")),
"list_stores must include 's'; got: {names}"
);
h.shutdown().await
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn kv_delete_reports_whether_key_existed() -> TestResult {
let h = TestHarness::start(false, false).await?;
call_tool(
&h.client,
"kv_set",
serde_json::json!({ "store": "s", "key": "k", "value": "v" }),
)
.await?;
let first = call_tool(
&h.client,
"kv_delete",
serde_json::json!({ "store": "s", "key": "k" }),
)
.await?;
assert_eq!(structured(&first)["deleted"], serde_json::json!(true));
let second = call_tool(
&h.client,
"kv_delete",
serde_json::json!({ "store": "s", "key": "k" }),
)
.await?;
assert!(!is_error(&second), "second delete must not error");
assert_eq!(structured(&second)["deleted"], serde_json::json!(false));
h.shutdown().await
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn kv_pop_removes_lowest_key_then_empty() -> TestResult {
let h = TestHarness::start(false, false).await?;
for (k, v) in [("b", "2"), ("a", "1")] {
call_tool(
&h.client,
"kv_set",
serde_json::json!({ "store": "q", "key": k, "value": v }),
)
.await?;
}
let pop1 = call_tool(&h.client, "kv_pop", serde_json::json!({ "store": "q" })).await?;
assert_eq!(structured(&pop1)["found"], serde_json::json!(true));
assert_eq!(
structured(&pop1)["key"],
serde_json::json!("a"),
"lowest key first"
);
assert_eq!(structured(&pop1)["value"], serde_json::json!("1"));
let pop2 = call_tool(&h.client, "kv_pop", serde_json::json!({ "store": "q" })).await?;
assert_eq!(structured(&pop2)["key"], serde_json::json!("b"));
let pop3 = call_tool(&h.client, "kv_pop", serde_json::json!({ "store": "q" })).await?;
assert_eq!(structured(&pop3)["found"], serde_json::json!(false));
h.shutdown().await
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn kv_clear_empties_the_store() -> TestResult {
let h = TestHarness::start(false, false).await?;
for k in ["x", "y", "z"] {
call_tool(
&h.client,
"kv_set",
serde_json::json!({ "store": "s", "key": k, "value": "1" }),
)
.await?;
}
let cleared = call_tool(&h.client, "kv_clear", serde_json::json!({ "store": "s" })).await?;
assert_eq!(structured(&cleared)["removed"], serde_json::json!(3));
let size = call_tool(&h.client, "kv_size", serde_json::json!({ "store": "s" })).await?;
assert_eq!(structured(&size)["size"], serde_json::json!(0));
h.shutdown().await
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn kv_database_routing_isolates_stores() -> TestResult {
let h = TestHarness::start(false, false).await?;
let set = call_tool(
&h.client,
"kv_set",
serde_json::json!({ "store": "s", "key": "k", "value": "persisted", "database": "persistent" }),
)
.await?;
assert!(
!is_error(&set),
"kv_set to persistent failed: {:?}",
first_text(&set)
);
let got_persist = call_tool(
&h.client,
"kv_get",
serde_json::json!({ "store": "s", "key": "k", "database": "persistent" }),
)
.await?;
assert_eq!(
structured(&got_persist)["value"],
serde_json::json!("persisted")
);
let got_default = call_tool(
&h.client,
"kv_get",
serde_json::json!({ "store": "s", "key": "k" }),
)
.await?;
assert_eq!(
structured(&got_default)["found"],
serde_json::json!(false),
"ephemeral DB must not see the persistent store's value"
);
let stores_persist = call_tool(
&h.client,
"kv_list_stores",
serde_json::json!({ "database": "persistent" }),
)
.await?;
assert!(
structured(&stores_persist)["stores"]
.as_array()
.is_some_and(|a| a.iter().any(|n| n == "s")),
"persistent list_stores must include 's'"
);
let stores_default = call_tool(&h.client, "kv_list_stores", serde_json::json!({})).await?;
assert_eq!(
structured(&stores_default)["count"],
serde_json::json!(0),
"default DB must have no stores"
);
h.shutdown().await
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn kv_persist_flag_routes_to_persistent() -> TestResult {
let h = TestHarness::start(false, false).await?;
call_tool(
&h.client,
"kv_set",
serde_json::json!({ "store": "s", "key": "k", "value": "v", "persist": true }),
)
.await?;
let got = call_tool(
&h.client,
"kv_get",
serde_json::json!({ "store": "s", "key": "k", "database": "persistent" }),
)
.await?;
assert_eq!(structured(&got)["value"], serde_json::json!("v"));
h.shutdown().await
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn kv_routes_to_attached_database() -> TestResult {
let h = TestHarness::start(false, false).await?;
let aux_dir = TempDir::new()?;
let aux_path = aux_dir.path().join("aux.hyper");
let attach = call_tool(
&h.client,
"attach_database",
serde_json::json!({
"alias": "aux",
"kind": "local_file",
"path": aux_path.to_string_lossy(),
"writable": true,
"on_missing": "create",
}),
)
.await?;
assert!(
!is_error(&attach),
"attach failed: {:?}",
first_text(&attach)
);
call_tool(
&h.client,
"kv_set",
serde_json::json!({ "store": "s", "key": "k", "value": "in_aux", "database": "aux" }),
)
.await?;
let got_aux = call_tool(
&h.client,
"kv_get",
serde_json::json!({ "store": "s", "key": "k", "database": "aux" }),
)
.await?;
assert_eq!(structured(&got_aux)["value"], serde_json::json!("in_aux"));
let got_default = call_tool(
&h.client,
"kv_get",
serde_json::json!({ "store": "s", "key": "k" }),
)
.await?;
assert_eq!(structured(&got_default)["found"], serde_json::json!(false));
h.shutdown().await
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn kv_set_persistent_on_ephemeral_only_errors() -> TestResult {
let h = TestHarness::start(false, true).await?;
let result = call_tool(
&h.client,
"kv_set",
serde_json::json!({ "store": "s", "key": "k", "value": "v", "database": "persistent" }),
)
.await?;
assert!(
is_error(&result),
"must reject persistent in ephemeral-only mode"
);
let msg = first_text(&result).unwrap_or_default();
assert!(
msg.contains("ephemeral-only") || msg.contains("persistent"),
"error must name the cause; got: {msg}"
);
h.shutdown().await
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn kv_read_only_server_blocks_mutators_allows_readers() -> TestResult {
let h = TestHarness::start(true, false).await?;
for (tool, args) in [
(
"kv_set",
serde_json::json!({ "store": "s", "key": "k", "value": "v" }),
),
("kv_delete", serde_json::json!({ "store": "s", "key": "k" })),
("kv_pop", serde_json::json!({ "store": "s" })),
("kv_clear", serde_json::json!({ "store": "s" })),
] {
let r = call_tool(&h.client, tool, args).await?;
assert!(is_error(&r), "{tool} must be blocked in read-only mode");
let msg = first_text(&r).unwrap_or_default();
assert!(
msg.contains("read-only"),
"{tool} error must mention read-only mode; got: {msg}"
);
}
let got = call_tool(
&h.client,
"kv_get",
serde_json::json!({ "store": "s", "key": "missing" }),
)
.await?;
assert!(
!is_error(&got),
"kv_get must work on a read-only server: {:?}",
first_text(&got)
);
assert_eq!(structured(&got)["found"], serde_json::json!(false));
let list = call_tool(&h.client, "kv_list", serde_json::json!({ "store": "s" })).await?;
assert!(
!is_error(&list),
"kv_list must work: {:?}",
first_text(&list)
);
assert_eq!(structured(&list)["count"], serde_json::json!(0));
let size = call_tool(&h.client, "kv_size", serde_json::json!({ "store": "s" })).await?;
assert!(
!is_error(&size),
"kv_size must work: {:?}",
first_text(&size)
);
assert_eq!(structured(&size)["size"], serde_json::json!(0));
h.shutdown().await
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn kv_persistent_value_survives_server_restart() -> TestResult {
let temp_dir = Arc::new(TempDir::new()?);
let workspace = temp_dir.path().join("workspace.hyper");
{
let h =
TestHarness::start_at(false, false, workspace.clone(), Arc::clone(&temp_dir)).await?;
let set = call_tool(
&h.client,
"kv_set",
serde_json::json!({ "store": "s", "key": "k", "value": "durable", "database": "persistent" }),
)
.await?;
assert!(!is_error(&set), "kv_set failed: {:?}", first_text(&set));
h.shutdown().await?;
}
let h2 = TestHarness::start_at(false, false, workspace, Arc::clone(&temp_dir)).await?;
let got = call_tool(
&h2.client,
"kv_get",
serde_json::json!({ "store": "s", "key": "k", "database": "persistent" }),
)
.await?;
assert_eq!(
structured(&got)["value"],
serde_json::json!("durable"),
"persistent value must survive a server restart"
);
h2.shutdown().await
}