subagents: add availableToSubagents opt-in toggle for extraMcpServers

This commit is contained in:
damocles 2026-09-13 13:18:27 +02:00 committed by mara
commit 16eec3c314
14 changed files with 356 additions and 84 deletions

View file

@ -13,6 +13,7 @@ axum.workspace = true
clap.workspace = true
hive-agent-sock.workspace = true
hive-claude.workspace = true
hive-extra-mcp.workspace = true
hive-sock-client.workspace = true
hive-types.workspace = true
rmcp.workspace = true

View file

@ -10,5 +10,6 @@
//! in-memory `name -> Cancel` map, live only as long as the process is.
pub mod mcp;
pub mod mcp_config;
pub mod paths;
pub mod session;

View file

@ -0,0 +1,56 @@
//! Builds a subagent's own filtered `--mcp-config`: only
//! `hyperhive.extraMcpServers` entries with `availableToSubagents = true`
//! (see `hive_extra_mcp::ExtraMcpServer::available_to_subagents`) ever reach
//! a subagent's claude invocation. Everything else — the built-in hyperhive
//! surface (todos/messaging), the auto-injected `bash` and `subagent`
//! entries — stays unreachable by construction: none of those default to
//! opted in, and the built-in surface isn't an `extraMcpServers` entry at
//! all, so there's no name for an operator to opt it in under even if they
//! wanted to.
use std::path::PathBuf;
/// Filename the rendered config lives at, under [`crate::paths::harness_dir`].
const CONFIG_FILE: &str = "subagent-mcp-config.json";
/// Render the subagent-eligible extra-MCP servers to a `--mcp-config` file,
/// returning its path — or `None` when no entry opts in (or the render/write
/// fails), so [`hive_claude::Config::mcp_config`] stays unset and the
/// subagent gets literally zero MCP servers, the same default as before this
/// toggle existed. Re-rendered on every call (cheap: a filter plus a small
/// file write) rather than cached once at daemon startup, so a config change
/// takes effect on this subagent's next `start`/`continue` without needing
/// the daemon itself restarted.
#[must_use]
pub fn build() -> Option<PathBuf> {
let state_dir = crate::paths::state_dir();
let servers: serde_json::Map<String, serde_json::Value> = hive_extra_mcp::load_extra_mcp()
.into_iter()
.filter(|(_, spec)| spec.available_to_subagents())
.map(|(name, spec)| (name, spec.to_json_entry(&state_dir)))
.collect();
if servers.is_empty() {
return None;
}
let body = serde_json::to_string_pretty(&serde_json::json!({ "mcpServers": servers }))
.unwrap_or_else(|_| "{}".into());
let dir = crate::paths::harness_dir();
if let Err(e) = std::fs::create_dir_all(&dir) {
tracing::warn!(
error = ?e,
dir = %dir.display(),
"subagent mcp-config: harness dir create failed; subagent gets zero extra MCP servers this turn",
);
return None;
}
let path = dir.join(CONFIG_FILE);
if let Err(e) = std::fs::write(&path, body) {
tracing::warn!(
error = ?e,
path = %path.display(),
"subagent mcp-config: write failed; subagent gets zero extra MCP servers this turn",
);
return None;
}
Some(path)
}

View file

@ -1,4 +1,4 @@
//! The one per-agent path this daemon needs.
//! The per-agent paths this daemon needs.
use std::path::PathBuf;
@ -13,3 +13,28 @@ pub fn agent_socket() -> PathBuf {
PathBuf::from,
)
}
/// Durable state directory for the current agent. Same resolution as
/// `hive-agent`'s own `paths::state_dir` — reads `HYPERHIVE_STATE_DIR`
/// (always injected via `systemd.globalEnvironment` by the meta flake),
/// falling back to the `HIVE_LABEL`-derived pattern for dev/test
/// environments where the env var may not be set. Copied rather than shared
/// with `hive-agent` (a binary crate with no lib target) — a five-line env
/// read isn't worth a crate dependency to dedupe.
#[must_use]
pub fn state_dir() -> PathBuf {
if let Some(p) = std::env::var_os("HYPERHIVE_STATE_DIR") {
return PathBuf::from(p);
}
let label = std::env::var("HIVE_LABEL").unwrap_or_default();
PathBuf::from(format!("/agents/{label}/state"))
}
/// Harness-internal scratch dir this daemon writes its own generated
/// subagent `--mcp-config` file into (see `crate::mcp_config`). Delegates to
/// the shared resolver so this and `hive-agent`'s own harness dir always
/// agree on the same path.
#[must_use]
pub fn harness_dir() -> PathBuf {
hive_agent_sock::paths::harness_dir()
}

View file

@ -185,8 +185,14 @@ fn subagent_otel_attrs(name: &str) -> String {
/// instructions. `dir`, when given, becomes `Config::cwd` (e.g. a worktree
/// the caller already prepared); `None` inherits this daemon's own working
/// directory, same as before this field existed. Always
/// `--dangerously-skip-permissions --strict-mcp-config` (no `--mcp-config`
/// override — a safety property, not a knob).
/// `--dangerously-skip-permissions --strict-mcp-config` — the safety
/// property is `strict_mcp_config: true` with no ambient MCP discovery, not
/// an unconditional absence of `--mcp-config`: a subagent gets exactly the
/// `hyperhive.extraMcpServers` entries an operator has explicitly opted in
/// via `availableToSubagents = true` (`crate::mcp_config::build`), nothing
/// implicit and nothing more. With no entry opted in — the default — that
/// resolves to `None` and the invocation is unchanged from before this
/// toggle existed: zero MCP servers, full stop.
fn build_config(
name: &str,
model: Option<String>,
@ -201,6 +207,7 @@ fn build_config(
Config {
model,
cwd: dir.map(PathBuf::from),
mcp_config: crate::mcp_config::build(),
strict_mcp_config: true,
extra_args,
env: vec![(