hyperhive/hive-subagent-mcp/src/mcp.rs

159 lines
6.3 KiB
Rust

//! The MCP tool surface: `start` / `continue` / `interrupt`, served
//! directly over streamable-http — no stdio bridge, no round-trip socket.
use std::sync::Arc;
use rmcp::{
ServerHandler,
handler::server::wrapper::Parameters,
schemars::{self, JsonSchema},
tool, tool_handler, tool_router,
};
use serde::Deserialize;
use crate::session::{self, State};
#[derive(Debug, Deserialize, JsonSchema)]
struct StartArgs {
/// Session name — becomes both this daemon's tracking key and claude's
/// own `--name`/`--resume` session title. Same identifier rules as the
/// `bash` server's task names: lowercase, digits, hyphen, max 63 chars.
/// Reusable once a prior *finished* session under that name is done —
/// rejected while one under the same name is still running.
name: String,
/// `--model` for the subagent's own claude invocation. Omit for
/// claude's own default. The `base:claude-subagents` skill's
/// "cheaper-than-you" guidance still applies here.
#[serde(default)]
model: Option<String>,
/// Path to a file passed as `--append-system-prompt-file` — the
/// subagent's actual task instructions. A file, not an inline string,
/// to avoid `ARG_MAX` on a large recipe.
prompt_file: String,
/// Written to the subagent's stdin as its first turn's prompt. Default:
/// a generic "carry out your instructions" nudge — the real task detail
/// belongs in `prompt_file`, not here.
#[serde(default = "default_trigger")]
trigger: String,
}
fn default_trigger() -> String {
"Carry out the task described in your instructions.".to_owned()
}
#[derive(Debug, Deserialize, JsonSchema)]
struct ContinueArgs {
/// The existing session's name (from a prior `start`).
name: String,
/// The new turn's prompt, written to the subagent's stdin.
prompt: String,
/// `--model` for this turn. Omit to let claude fall back to its own
/// default — this does not have to match whatever model `start` used.
#[serde(default)]
model: Option<String>,
}
#[derive(Debug, Deserialize, JsonSchema)]
struct InterruptArgs {
/// The running session's name to signal.
name: String,
/// `true` sends SIGKILL immediately; `false` (default) sends SIGINT,
/// letting claude shut down cleanly if it's already mid-response.
#[serde(default)]
force: bool,
}
#[derive(Clone)]
struct SubagentMcp {
state: Arc<State>,
}
#[tool_router]
impl SubagentMcp {
#[tool(
description = "Start a fresh claude subagent session under `name`, running in the \
background. Returns as soon as the process is confirmed running — not once it \
finishes; poll for completion via the todo this daemon pushes when the turn ends, \
or use `continue` later to give it another turn. A prior *finished* session under \
the same name is archived first (real fresh start, not a silent resume); a \
*currently running* one is refused. Always runs with \
`--dangerously-skip-permissions --strict-mcp-config` (no `--mcp-config` override — \
that's a safety property, not a knob). See the `base:claude-subagents` skill for \
when to reach for this."
)]
fn start(&self, Parameters(args): Parameters<StartArgs>) -> String {
match session::start(
&self.state,
&args.name,
args.model,
&args.prompt_file,
args.trigger,
) {
Ok(msg) => msg,
Err(e) => format!("start error: {e:#}"),
}
}
#[tool(
name = "continue",
description = "Give an existing named subagent session a new turn — whether that's \
because its previous turn finished and you have a follow-up instruction, or you're \
reattaching after this daemon restarted (the session itself survives independently \
of the daemon that spawned it). Returns as soon as confirmed running, same as \
`start`. Refuses a name with no session on disk at all, or one already running."
)]
fn r#continue(&self, Parameters(args): Parameters<ContinueArgs>) -> String {
match session::continue_(&self.state, &args.name, args.prompt, args.model) {
Ok(msg) => msg,
Err(e) => format!("continue error: {e:#}"),
}
}
#[tool(
description = "Signal a currently-running subagent session to stop. Only works while \
it's actually running — there's no queued/pending state to cancel pre-emptively, \
only running or not tracked at all. `force: true` for SIGKILL, otherwise SIGINT."
)]
fn interrupt(&self, Parameters(args): Parameters<InterruptArgs>) -> String {
match session::interrupt(&self.state, &args.name, args.force) {
Ok(msg) => msg,
Err(e) => format!("interrupt error: {e:#}"),
}
}
}
#[tool_handler]
impl ServerHandler for SubagentMcp {}
/// Run the MCP server over HTTP (rmcp streamable-http transport) on `addr`.
/// Loopback-only bind, one long-lived session — same shape as the bash and
/// matrix daemons' own `serve_http`.
///
/// # 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, state: Arc<State>) -> anyhow::Result<()> {
use rmcp::transport::streamable_http_server::{
StreamableHttpServerConfig, StreamableHttpService, session::local::LocalSessionManager,
};
let mut session_manager = LocalSessionManager::default();
// A subagent turn can run considerably longer than a bash command —
// same 24h keep-alive rationale as the bash/matrix daemons.
session_manager.session_config.keep_alive = Some(std::time::Duration::from_hours(24));
let session_manager = std::sync::Arc::new(session_manager);
let service = StreamableHttpService::new(
move || {
Ok(SubagentMcp {
state: Arc::clone(&state),
})
},
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-subagent MCP over streamable-http at /mcp");
axum::serve(listener, app).await?;
Ok(())
}