use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
use rmcp::model::CallToolRequestParams;
use rmcp::service::RunningService;
use rmcp::{RoleClient, ServiceExt};
use scryer_mcp::daemon::{
self, ControlCommand, DaemonPaths, Hello, HelloMode, client::spawn_daemon_process,
};
use serde_json::Value;
const STARTUP_TIMEOUT: Duration = Duration::from_secs(15);
const STOP_TIMEOUT: Duration = Duration::from_secs(10);
pub type DaemonClient = RunningService<RoleClient, ()>;
pub async fn serve_stdio(db_path: &str, watch: bool, idle_timeout_secs: u64) -> anyhow::Result<()> {
let paths = DaemonPaths::for_db(Path::new(db_path));
let exe = std::env::current_exe()?;
if let Err(e) = daemon::replace_stale_daemon(&paths).await {
eprintln!("warning: could not replace the stale Scryer daemon: {e}");
}
let stream = daemon::connect_or_spawn(
&paths,
|paths| spawn_daemon_process(&exe, paths, idle_timeout_secs),
STARTUP_TIMEOUT,
)
.await?;
let hello = Hello::new(HelloMode::Mcp {
cwd: current_dir(),
watch,
});
let conn = daemon::handshake(stream, &hello).await?;
warn_on_version_mismatch(&conn.reply.version, conn.reply.build.as_deref());
daemon::pipe(
tokio::io::stdin(),
tokio::io::stdout(),
conn.reader,
conn.writer,
)
.await?;
Ok(())
}
pub async fn run(db_path: &str, idle_timeout_secs: u64) -> anyhow::Result<()> {
if !daemon::is_local_db(db_path) {
anyhow::bail!("The Scryer daemon serves local database files only, not '{db_path}'");
}
let idle_timeout = (idle_timeout_secs > 0).then(|| Duration::from_secs(idle_timeout_secs));
daemon::run_daemon(DaemonPaths::for_db(Path::new(db_path)), idle_timeout).await
}
pub async fn status(db_path: &str) -> anyhow::Result<()> {
let paths = DaemonPaths::for_db(Path::new(db_path));
let Some(reply) = daemon::control(&paths, ControlCommand::Status).await? else {
if daemon::daemon_running(&paths) {
println!(
"Scryer daemon for {} is starting up.",
paths.db_path.display()
);
} else {
println!(
"No Scryer daemon is running for {}.",
paths.db_path.display()
);
}
return Ok(());
};
let Some(status) = reply.status else {
anyhow::bail!("Scryer daemon returned no status");
};
println!(
"Scryer daemon (pid {}, v{}, build {})",
status.pid,
status.version,
status.build.as_deref().unwrap_or("unknown")
);
println!("Database: {}", status.db_path.display());
println!("Socket: {}", paths.socket.display());
println!("Log: {}", paths.log_file.display());
println!("Uptime: {}s", status.uptime_secs);
println!("Sessions: {}", status.sessions);
if status.watched.is_empty() {
println!("Watching: nothing");
} else {
println!("Watching:");
for project in status.watched {
println!(
" - [{}] {} ({} director{})",
project.project_id,
project.root.display(),
project.directories,
if project.directories == 1 { "y" } else { "ies" }
);
}
}
warn_on_version_mismatch(&status.version, status.build.as_deref());
Ok(())
}
pub async fn stop(db_path: &str) -> anyhow::Result<()> {
let paths = DaemonPaths::for_db(Path::new(db_path));
if daemon::control(&paths, ControlCommand::Stop)
.await?
.is_none()
{
println!(
"No Scryer daemon is running for {}.",
paths.db_path.display()
);
return Ok(());
}
let deadline = Instant::now() + STOP_TIMEOUT;
while daemon::daemon_running(&paths) {
if Instant::now() >= deadline {
anyhow::bail!("Scryer daemon did not exit within {STOP_TIMEOUT:?}");
}
tokio::time::sleep(Duration::from_millis(50)).await;
}
println!("Stopped the Scryer daemon for {}.", paths.db_path.display());
Ok(())
}
pub async fn connect_client(db_path: &str) -> anyhow::Result<Option<DaemonClient>> {
if !daemon::is_local_db(db_path) {
return Ok(None);
}
let paths = DaemonPaths::for_db(Path::new(db_path));
if let Err(e) = daemon::replace_stale_daemon(&paths).await {
eprintln!("warning: could not replace the stale Scryer daemon: {e}");
}
let Ok(stream) = daemon::connect(&paths).await else {
return Ok(None);
};
let hello = Hello::new(HelloMode::Mcp {
cwd: current_dir(),
watch: false,
});
let conn = daemon::handshake(stream, &hello).await?;
warn_on_version_mismatch(&conn.reply.version, conn.reply.build.as_deref());
let client = ().serve((conn.reader, conn.writer)).await?;
Ok(Some(client))
}
pub async fn connect_or_spawn_client(
db_path: &str,
idle_timeout_secs: u64,
) -> anyhow::Result<DaemonClient> {
if !daemon::is_local_db(db_path) {
anyhow::bail!("The Scryer daemon serves local database files only, not '{db_path}'");
}
let paths = DaemonPaths::for_db(Path::new(db_path));
let exe = std::env::current_exe()?;
if let Err(e) = daemon::replace_stale_daemon(&paths).await {
eprintln!("warning: could not replace the stale Scryer daemon: {e}");
}
let stream = daemon::connect_or_spawn(
&paths,
|paths| spawn_daemon_process(&exe, paths, idle_timeout_secs),
STARTUP_TIMEOUT,
)
.await?;
let hello = Hello::new(HelloMode::Mcp {
cwd: current_dir(),
watch: false,
});
let conn = daemon::handshake(stream, &hello).await?;
warn_on_version_mismatch(&conn.reply.version, conn.reply.build.as_deref());
let client = ().serve((conn.reader, conn.writer)).await?;
Ok(client)
}
pub async fn call_tool_raw(
client: &DaemonClient,
name: &str,
args: Value,
) -> anyhow::Result<(String, bool)> {
let Value::Object(args) = args else {
anyhow::bail!("tool arguments must be a JSON object");
};
let result = client
.call_tool(CallToolRequestParams::new(name.to_string()).with_arguments(args))
.await?;
let text = result
.content
.iter()
.find_map(|block| block.as_text())
.map(|text| text.text.to_string())
.unwrap_or_default();
let is_err = result.is_error.unwrap_or(false);
Ok((text, is_err))
}
pub async fn call_tool(client: &DaemonClient, name: &str, args: Value) -> anyhow::Result<Value> {
let (text, is_err) = call_tool_raw(client, name, args).await?;
if is_err {
anyhow::bail!("{text}");
}
Ok(serde_json::from_str(&text)?)
}
fn current_dir() -> Option<PathBuf> {
std::env::current_dir()
.ok()
.map(|dir| dunce::canonicalize(&dir).unwrap_or(dir))
}
fn warn_on_version_mismatch(daemon_version: &str, daemon_build: Option<&str>) {
if daemon_version != scryer_mcp::mcp_version() {
eprintln!(
"warning: the running Scryer daemon is v{daemon_version} but this binary is v{}; \
run `scryer daemon stop` to restart it on the next connection",
scryer_mcp::mcp_version()
);
} else if daemon::is_stale_build(daemon_build) {
eprintln!(
"warning: the running Scryer daemon was started from a different build of this binary; \
run `scryer daemon stop` to restart it on the next connection"
);
}
}