//! MCP tool surface for `hive-bash-daemon`, served directly over //! streamable-http — no stdio bridge, no round-trip socket. The daemon //! already owns the subprocess runner in-process, so the tool handlers //! below call straight into [`crate::runner`]. Mirrors //! `hive-agent-mcp::mcp::serve_http`'s shape (persistent daemon, stable //! URL claude reconnects to every turn instead of respawning a stdio //! child). use std::fmt::Write as _; use rmcp::{ ServerHandler, handler::server::wrapper::Parameters, schemars::{self, JsonSchema}, tool, tool_handler, tool_router, }; use serde::Deserialize; use crate::protocol::{TaskFile, TaskStatus}; use crate::runner::{self, SUMMARY_BYTES}; fn status_str(status: &TaskStatus) -> &'static str { match status { TaskStatus::Pending => "pending", TaskStatus::Running => "running", TaskStatus::Done => "done", TaskStatus::TimedOut => "timed_out", TaskStatus::Interrupted => "interrupted", TaskStatus::Killed => "killed", } } /// Format a `TaskFile` as the human-readable status string claude sees. fn format_task(task: &TaskFile) -> String { let mut out = format!("task `{}`: status={}", task.id, status_str(&task.status)); if let Some(code) = task.exit_code { let _ = write!(out, ", exit={code}"); } if let (Some(started), None) = (task.started_at, task.completed_at) { let elapsed = (chrono::Utc::now() - started).num_seconds(); let _ = write!(out, ", running for {elapsed}s"); } if let (Some(completed), Some(started)) = (task.completed_at, task.started_at) { let took = (completed - started).num_seconds(); let _ = write!(out, ", took {took}s"); } let out_file = crate::paths::task_out(&task.id); let err_file = crate::paths::task_err(&task.id); let out_len = std::fs::metadata(&out_file).map_or(0, |m| m.len()); let err_len = std::fs::metadata(&err_file).map_or(0, |m| m.len()); if let Some(stdout) = &task.stdout_tail { let s = stdout.trim(); if !s.is_empty() { let _ = write!(out, "\n\nstdout:\n```\n{s}\n```"); } } if out_len > SUMMARY_BYTES as u64 { let _ = write!(out, "\n\nFull stdout lives in `{}`", out_file.display()); } if let Some(stderr) = &task.stderr_tail { let s = stderr.trim(); if !s.is_empty() { let _ = write!(out, "\n\nstderr:\n```\n{s}\n```"); } } if err_len > SUMMARY_BYTES as u64 { let _ = write!(out, "\n\nFull stderr lives in `{}`", err_file.display()); } out } /// Appended to a `status` result when the caller parked on a /// `wait_seconds` poll that expired while the task was still running — /// nudges the model to spend the idle time on other work instead of /// immediately re-waiting on the same task. const BASH_IDLE_WAIT_HINT: &str = "\n\nThe task is still running — your wait timed out before it \ finished. If you have other useful work, do that and check back later (the task keeps running, and \ it surfaces in your loose-ends when it completes) rather than immediately re-waiting."; // --------------------------------------------------------------------------- // MCP server // --------------------------------------------------------------------------- #[derive(Debug, Deserialize, JsonSchema)] struct BashRunArgs { /// Shell command to run (passed to `bash -c`). cmd: String, /// Timeout in seconds. Defaults to `None` (no timeout) — task runs until /// natural exit. Pass an explicit value to kill the task after N seconds /// and mark it `timed_out`. #[serde(default)] timeout_secs: Option, /// Optional inline wait: `run` polls for up to `wait_seconds` /// (capped at 30) before returning. When the task finishes within the /// window the full status is returned immediately (nothing to handle /// later); when the timeout expires the task keeps running and the /// normal `task started: id=` response is returned. Defaults to 3s. /// Pass `0` to disable inline waiting and always get the immediate response. #[serde(default = "default_wait")] wait_seconds: Option, /// Optional task name. When set it becomes the task id, so it appears /// in `status` lookups and your loose-ends list — handy for recognising /// a task later instead of an opaque hex id. A name can be reused once /// its previous task has finished; reusing a name whose task is still /// running is rejected. Allowed chars: `[a-z0-9-]` (a valid /// identifier — lowercase, digits, hyphen; max 63). Omit to get the /// auto-generated id. #[serde(default)] name: Option, } #[allow( clippy::unnecessary_wraps, reason = "serde `#[serde(default = ...)]` requires the default fn's return type to match the field's `Option`, so the `Some` wrap is mandatory even though the value is constant" )] fn default_wait() -> Option { Some(3) } #[derive(Debug, Deserialize, JsonSchema)] struct BashStatusArgs { /// Task ID returned by `run`. id: String, /// Optional inline wait: `status` polls for up to `wait_seconds` /// (capped at 30) before returning. Useful to avoid a separate /// round-trip when the task is expected to finish soon. #[serde(default)] wait_seconds: Option, } #[derive(Debug, Deserialize, JsonSchema)] struct BashKillArgs { /// Task ID (from `run`) to kill. id: String, /// `false` (default): SIGINT to the task's process group — graceful, the /// process can clean up. `true`: SIGKILL. Either way the whole process /// group is signalled, so children of the shell (e.g. a `cargo`/`nix` /// invocation) are stopped too, not just the shell. Fire-and-forget: the /// call sends the signal and returns without waiting. If a SIGINT'd task /// doesn't exit, call `kill` again with `force: true` to send SIGKILL. #[serde(default)] force: bool, } #[derive(Clone)] struct BashMcp; #[tool_router] impl BashMcp { #[tool( description = "Run a shell command in the background. Returns a task ID immediately — \ do NOT wait inline. When the command finishes it surfaces as a todo in your \ loose-ends (the exit status plus a `Read()` pointer to the captured \ `.out`/`.err` files — read only what you need); handle it on a future turn. Use \ `status` to poll within the same turn if needed. `timeout_secs` defaults to \ `None` (no timeout) — task runs until natural exit; pass a value to kill after \ N seconds. Pass `wait_seconds` (capped at 30) to wait inline for fast commands: \ if the task finishes within the window you get the full status immediately \ (nothing to handle later); otherwise it keeps running and you get \ `task started: id=`. Defaults to 3; pass `0` to disable inline waiting. \ Pass `name` to label the task with a memorable id (shown in `status` lookups \ and your loose-ends) instead of an opaque hex id; reusable once the prior task \ has finished, rejected while one is still running." )] async fn run(&self, Parameters(args): Parameters) -> String { let id = match runner::submit_task(args.cmd, args.timeout_secs, args.name) { Ok(id) => id, Err(e) => return format!("bash_run error: {e:#}"), }; let wait = args.wait_seconds.unwrap_or(0); if wait > 0 && let Some(task) = runner::wait_for_task(&id, wait).await && matches!( task.status, TaskStatus::Done | TaskStatus::TimedOut | TaskStatus::Interrupted | TaskStatus::Killed ) { return format_task(&task); } format!("task started: id={id}") } #[tool( description = "Check the status of a background bash task by its ID (from `run`). \ Returns the current status (pending/running/done/timed_out/interrupted/killed), exit \ code if finished, and a tail of stdout/stderr. Full output lives in \ `harness/bash-tasks/.out` / `.err`. \ Pass `wait_seconds` (capped at 30) to wait inline for the task to finish: when the \ task finishes within the window the full status is returned immediately. Useful to \ avoid a separate round-trip when the task is expected to finish soon." )] async fn status(&self, Parameters(args): Parameters) -> String { let waited = args.wait_seconds.is_some_and(|w| w > 0); let wait = args.wait_seconds.unwrap_or(0); let task = if wait > 0 { runner::wait_for_task(&args.id, wait).await } else { runner::read_task(&args.id) }; match task { Some(t) => { let mut out = format_task(&t); if waited && matches!(t.status, TaskStatus::Pending | TaskStatus::Running) { out.push_str(BASH_IDLE_WAIT_HINT); } out } None => format!("unknown task id `{}`", args.id), } } #[tool( description = "Kill a background bash task you started (by its ID from `run`). \ `force: false` (default) sends SIGINT — graceful, lets the process clean up; \ `force: true` sends SIGKILL. Either way the task's whole process group is \ signalled, so a runaway child (cargo/nix/etc.) is stopped too, not just the shell. \ Fire-and-forget: sends the signal and returns without waiting. If a SIGINT'd task \ doesn't exit, call kill again with `force: true` to SIGKILL. A still-pending task \ is cancelled before it starts. The task ends as `killed` and surfaces in your \ loose-ends like any completion." )] async fn kill(&self, Parameters(args): Parameters) -> String { let (killed, was_running) = runner::kill_task(&args.id, args.force); if !killed { return format!( "no running or pending task with id `{}` (already finished or unknown)", args.id ); } if was_running { let sig = if args.force { "SIGKILL" } else { "SIGINT" }; format!( "task `{}`: {sig} sent to its process group; it transitions to `killed` \ once the process exits and then surfaces in your loose-ends.", args.id ) } else { format!( "task `{}` was pending — cancelled before it started.", args.id ) } } } #[tool_handler] impl ServerHandler for BashMcp {} /// Run the MCP server over HTTP (rmcp streamable-http transport) on `addr`. /// /// Sole transport — there is no stdio mode. Long-lived so claude /// reconnects to the stable URL each turn instead of respawning a stdio /// child; since the daemon already owns [`crate::runner`] in-process, /// tool calls need no round-trip to anywhere. /// /// Binds loopback only in practice; the default `allowed_hosts` /// (`localhost`/`127.0.0.1`/`::1`) rejects Host headers from anywhere else. /// /// # Errors /// /// Returns an error if the listener cannot bind `addr` or the HTTP server /// exits with a fatal error. pub async fn serve_http(addr: std::net::SocketAddr) -> anyhow::Result<()> { use rmcp::transport::streamable_http_server::{ StreamableHttpServerConfig, StreamableHttpService, session::local::LocalSessionManager, }; let session_manager = std::sync::Arc::new(LocalSessionManager::default()); let service = StreamableHttpService::new( || Ok(BashMcp), session_manager, StreamableHttpServerConfig::default(), ); let app = axum::Router::new().nest_service("/mcp", service); let listener = tokio::net::TcpListener::bind(addr).await?; tracing::info!(%addr, "serving hive-bash MCP over streamable-http at /mcp"); axum::serve(listener, app).await?; Ok(()) } #[cfg(test)] mod status_hint_tests { use super::{BASH_IDLE_WAIT_HINT, format_task}; use crate::protocol::{TaskFile, TaskStatus}; use crate::test_util::with_harness_dir; fn task(status: TaskStatus) -> TaskFile { TaskFile { id: "t1".to_owned(), cmd: "echo hi".to_owned(), timeout_secs: None, status, created_at: chrono::DateTime::from_timestamp(1, 0).unwrap_or_default(), started_at: Some(chrono::DateTime::from_timestamp(1, 0).unwrap_or_default()), completed_at: None, exit_code: None, stdout_tail: None, stderr_tail: None, } } #[test] fn running_task_after_wait_appends_idle_hint() { with_harness_dir(|| { let t = task(TaskStatus::Running); let mut out = format_task(&t); out.push_str(BASH_IDLE_WAIT_HINT); assert!(out.contains(BASH_IDLE_WAIT_HINT)); }); } #[test] fn done_task_formats_without_hint() { with_harness_dir(|| { let out = format_task(&task(TaskStatus::Done)); assert!(!out.contains(BASH_IDLE_WAIT_HINT)); assert!(out.contains("status=done")); }); } }