diff --git a/Cargo.lock b/Cargo.lock index aa828666..570e2c91 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1654,6 +1654,7 @@ dependencies = [ "hive-agent-sock", "hive-claude", "hive-core-agent-sock", + "hive-extra-mcp", "hive-sh4re", "hive-sock-client", "http-body-util", @@ -1805,6 +1806,15 @@ dependencies = [ "serde", ] +[[package]] +name = "hive-extra-mcp" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", + "tracing", +] + [[package]] name = "hive-forge" version = "0.1.0" @@ -1992,6 +2002,7 @@ dependencies = [ "clap", "hive-agent-sock", "hive-claude", + "hive-extra-mcp", "hive-sock-client", "hive-types", "rmcp", diff --git a/Cargo.toml b/Cargo.toml index a4d1206f..2f660c5e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,6 +7,7 @@ members = [ "hive-core-agent-sock", "hive-bash-mcp", "hive-c0re", + "hive-extra-mcp", "hive-screen-mcp", "hive-forge", "hive-forge-notify", @@ -85,6 +86,7 @@ hive-jobq = { path = "hive-jobq" } hive-jobq-metrics = { path = "hive-jobq-metrics" } hive-jobq-wire = { path = "hive-jobq-wire" } hive-core-agent-sock = { path = "hive-core-agent-sock" } +hive-extra-mcp = { path = "hive-extra-mcp" } hive-claude = "0.1.1" hive-host-sock = { path = "hive-host-sock" } hive-priv-sock = { path = "hive-priv-sock" } diff --git a/docs/tools/subagent.md b/docs/tools/subagent.md index 6f19d885..57d8393c 100644 --- a/docs/tools/subagent.md +++ b/docs/tools/subagent.md @@ -54,3 +54,21 @@ agent that needs a stable or non-default port. Own systemd unit, defined alongside the other per-agent MCP daemons in `nix/agent-modules/mcp.nix`. + +## MCP servers available to a subagent + +A subagent runs with `--strict-mcp-config` and no `--mcp-config` by +default — zero MCP servers, full stop; it falls back to claude's own +native tools (`Bash`, `WebFetch`, etc.), not the parent's `mcp__bash__*` / +`mcp__hyperhive__*` surface. Nothing implicit reaches it: the built-in +hyperhive surface (todos/messaging) isn't an `extraMcpServers` entry at +all, and the auto-injected `bash`/`subagent` entries default to excluded +too (a subagent can't spawn hive-bash tasks or its own nested subagents +unless an operator opts them in explicitly, same as anything else). + +Set `hyperhive.extraMcpServers..availableToSubagents = true` on a +specific entry to hand that one server to subagents as well — useful for, +say, a read-only lookup or scraper MCP a subagent's bounded, single-batch +task might need. `hive-subagent-mcp`'s `mcp_config` module renders the +opted-in subset into its own `--mcp-config` file per turn; an entry left +at the default `false` never appears there. diff --git a/docs/turn-loop/mcp.md b/docs/turn-loop/mcp.md index 64152412..90abcc89 100644 --- a/docs/turn-loop/mcp.md +++ b/docs/turn-loop/mcp.md @@ -13,7 +13,10 @@ entry (`type = "stdio" | "http"`, default `"stdio"`): `matrix` stays a stdio bridge, `bash` runs its own persistent streamable-http listener (`hive-bash-daemon`) — same reasoning as the built-in surface. The server name is `hyperhive`, so the tools land in claude as -`mcp__hyperhive__`. +`mcp__hyperhive__`. Each entry also has an `availableToSubagents` +toggle (default `false`) — see +[`docs/tools/subagent.md`](../tools/subagent.md#mcp-servers-available-to-a-subagent) +for what reaches a subagent's own `--mcp-config` and what doesn't. Tool groups (`HIVE_TOOL_GROUPS`) gate tool access. The default preset (`AGENT_DEFAULT`) includes `messaging`, `meta`, `inbox`, and diff --git a/hive-agent/Cargo.toml b/hive-agent/Cargo.toml index 14478708..d5d31c93 100644 --- a/hive-agent/Cargo.toml +++ b/hive-agent/Cargo.toml @@ -23,6 +23,7 @@ clap.workspace = true hive-claude.workspace = true hive-agent-sock.workspace = true hive-core-agent-sock.workspace = true +hive-extra-mcp.workspace = true hive-sh4re.workspace = true hive-sock-client.workspace = true libc.workspace = true diff --git a/hive-agent/src/mcp_config.rs b/hive-agent/src/mcp_config.rs index 614518e8..da8fd869 100644 --- a/hive-agent/src/mcp_config.rs +++ b/hive-agent/src/mcp_config.rs @@ -184,7 +184,7 @@ pub fn allowed_mcp_tools(groups: &[hive_sh4re::permissions::ToolGroup]) -> Vec__` so claude can call // them without per-tool operator approval. `["*"]` (the default) // expands to `mcp____*` — every tool from that server. - for (server, spec) in load_extra_mcp() { + for (server, spec) in hive_extra_mcp::load_extra_mcp() { if server == SERVER_NAME || !extra_server_enabled(&server, groups) { continue; } @@ -241,70 +241,6 @@ pub fn builtin_tools_arg() -> String { tools.join(",") } -/// Where the NixOS module writes the per-agent extra-MCP spec (see -/// `nix/agent-modules/mcp.nix`). Each entry becomes an additional -/// `mcpServers.` block in the rendered claude config + a -/// `mcp____` pattern in `--allowedTools`. -const EXTRA_MCP_PATH: &str = "/etc/hyperhive/extra-mcp.json"; - -/// An extra MCP server declared via `hyperhive.extraMcpServers`. Two -/// transports: `Stdio` (claude spawns `command` fresh each turn, talks -/// JSON-RPC over its stdin/stdout) and `Http` (claude points at a -/// long-lived streamable-http `url` instead — no per-turn spawn, no -/// re-registration race, same shape as the built-in hyperhive surface). -/// Internally tagged on the nix-rendered `type` field; unrecognised -/// fields for the inactive variant (e.g. `command` on an `Http` entry) -/// are ignored by serde's default struct deserialization. -#[derive(Debug, serde::Deserialize)] -#[serde(tag = "type", rename_all = "snake_case")] -enum ExtraMcpServer { - Stdio { - command: String, - #[serde(default)] - args: Vec, - #[serde(default)] - env: std::collections::BTreeMap, - #[serde(default = "default_allowed_tools")] - #[serde(rename = "allowedTools")] - allowed_tools: Vec, - }, - Http { - url: String, - #[serde(default = "default_allowed_tools")] - #[serde(rename = "allowedTools")] - allowed_tools: Vec, - }, -} - -impl ExtraMcpServer { - fn allowed_tools(&self) -> &[String] { - match self { - Self::Stdio { allowed_tools, .. } | Self::Http { allowed_tools, .. } => allowed_tools, - } - } -} - -fn default_allowed_tools() -> Vec { - vec!["*".to_owned()] -} - -/// Read + parse the extra-MCP spec. Returns an empty map when -/// the file is missing or unparsable (the agent has none configured, -/// or the file is malformed — both cases degrade to "no extra servers"). -fn load_extra_mcp() -> std::collections::BTreeMap { - let Ok(raw) = std::fs::read_to_string(EXTRA_MCP_PATH) else { - return std::collections::BTreeMap::new(); - }; - serde_json::from_str(&raw).unwrap_or_else(|e| { - tracing::warn!( - path = EXTRA_MCP_PATH, - error = ?e, - "extra-mcp spec parse failed; ignoring", - ); - std::collections::BTreeMap::new() - }) -} - /// Render the MCP config blob claude reads from `--mcp-config `. /// The built-in `hyperhive` surface is an HTTP entry pointing at the /// persistent `hive-mcp-http` daemon (see [`DEFAULT_MCP_HTTP_PORT`]); there @@ -364,7 +300,7 @@ fn build_mcp_servers() -> serde_json::Map { // agent isn't entitled to must not even appear in the MCP config, or the // agent could call it directly (there is no later enforcement point). let groups = effective_tool_groups(); - for (name, spec) in load_extra_mcp() { + for (name, spec) in hive_extra_mcp::load_extra_mcp() { if name == SERVER_NAME { tracing::warn!( "extra MCP server name `{SERVER_NAME}` collides with the built-in surface; ignoring", @@ -378,20 +314,7 @@ fn build_mcp_servers() -> serde_json::Map { ); continue; } - let entry = match spec { - ExtraMcpServer::Stdio { - command, - args, - mut env, - .. - } => { - env.entry("HYPERHIVE_STATE_DIR".to_owned()) - .or_insert_with(|| state_dir.display().to_string()); - serde_json::json!({ "command": command, "args": args, "env": env }) - } - ExtraMcpServer::Http { url, .. } => serde_json::json!({ "type": "http", "url": url }), - }; - servers.insert(name, entry); + servers.insert(name, spec.to_json_entry(&state_dir)); } servers } diff --git a/hive-extra-mcp/Cargo.toml b/hive-extra-mcp/Cargo.toml new file mode 100644 index 00000000..3954c7bf --- /dev/null +++ b/hive-extra-mcp/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "hive-extra-mcp" +edition.workspace = true +version.workspace = true + +[lints] +workspace = true + +[dependencies] +serde.workspace = true +serde_json.workspace = true +tracing.workspace = true + +# Shared spec for `hyperhive.extraMcpServers` — see src/lib.rs. Split out of +# `hive-agent` (its only consumer until now) so `hive-subagent-mcp` can build +# its own filtered subagent `--mcp-config` without depending on a binary +# crate that has no lib target — same rationale as the `*-sock` crate splits +# elsewhere in this workspace. diff --git a/hive-extra-mcp/src/lib.rs b/hive-extra-mcp/src/lib.rs new file mode 100644 index 00000000..c9cca275 --- /dev/null +++ b/hive-extra-mcp/src/lib.rs @@ -0,0 +1,185 @@ +//! Shared spec for `hyperhive.extraMcpServers` entries: the on-disk JSON +//! shape the nix module (`nix/agent-modules/mcp.nix`) renders to +//! `/etc/hyperhive/extra-mcp.json`, plus the parsing and per-entry +//! JSON-rendering logic every consumer needs. Split out of `hive-agent` +//! (its only consumer until now) so `hive-subagent-mcp` can build its own +//! filtered subagent `--mcp-config` without depending on a binary crate that +//! has no lib target — same rationale as the `*-sock` crate splits +//! elsewhere in this workspace. + +use std::collections::BTreeMap; +use std::path::Path; + +/// Where the NixOS module writes the per-agent extra-MCP spec. Each entry +/// becomes an additional `mcpServers.` block in a rendered claude +/// `--mcp-config`. +pub const EXTRA_MCP_PATH: &str = "/etc/hyperhive/extra-mcp.json"; + +/// An extra MCP server declared via `hyperhive.extraMcpServers`. Two +/// transports: `Stdio` (the consumer spawns `command` fresh each turn, talks +/// JSON-RPC over its stdin/stdout) and `Http` (point claude at a long-lived +/// streamable-http `url` instead — no per-turn spawn, no re-registration +/// race). Internally tagged on the nix-rendered `type` field; unrecognised +/// fields for the inactive variant (e.g. `command` on an `Http` entry) are +/// ignored by serde's default struct deserialization. +#[derive(Debug, Clone, serde::Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum ExtraMcpServer { + Stdio { + command: String, + #[serde(default)] + args: Vec, + #[serde(default)] + env: BTreeMap, + #[serde(default = "default_allowed_tools", rename = "allowedTools")] + allowed_tools: Vec, + #[serde(default, rename = "availableToSubagents")] + available_to_subagents: bool, + }, + Http { + url: String, + #[serde(default = "default_allowed_tools", rename = "allowedTools")] + allowed_tools: Vec, + #[serde(default, rename = "availableToSubagents")] + available_to_subagents: bool, + }, +} + +impl ExtraMcpServer { + #[must_use] + pub fn allowed_tools(&self) -> &[String] { + match self { + Self::Stdio { allowed_tools, .. } | Self::Http { allowed_tools, .. } => allowed_tools, + } + } + + /// Whether this entry is opted in to subagent sessions + /// (`hyperhive.extraMcpServers..availableToSubagents`, default + /// `false`). Subagents run `--strict-mcp-config` with no discovery, so + /// this flag is the only path an entry reaches a subagent's own + /// `--mcp-config` at all — see `hive-subagent-mcp`'s `mcp_config` + /// module. + #[must_use] + pub fn available_to_subagents(&self) -> bool { + match self { + Self::Stdio { + available_to_subagents, + .. + } + | Self::Http { + available_to_subagents, + .. + } => *available_to_subagents, + } + } + + /// Render this entry into the shape claude expects under + /// `mcpServers.` in a `--mcp-config` blob. `state_dir` is injected + /// as `HYPERHIVE_STATE_DIR` into a stdio entry's env when the entry + /// doesn't already set it, so an extra stdio MCP server can resolve the + /// agent's durable state dir without its author hard-coding it; an http + /// entry has no child-process env to inject into (the daemon behind the + /// URL resolves its own state dir independently). + #[must_use] + pub fn to_json_entry(&self, state_dir: &Path) -> serde_json::Value { + match self { + Self::Stdio { + command, args, env, .. + } => { + let mut env = env.clone(); + env.entry("HYPERHIVE_STATE_DIR".to_owned()) + .or_insert_with(|| state_dir.display().to_string()); + serde_json::json!({ "command": command, "args": args, "env": env }) + } + Self::Http { url, .. } => serde_json::json!({ "type": "http", "url": url }), + } + } +} + +fn default_allowed_tools() -> Vec { + vec!["*".to_owned()] +} + +/// Read + parse the extra-MCP spec from [`EXTRA_MCP_PATH`]. Returns an empty +/// map when the file is missing or unparsable (the agent has none +/// configured, or the file is malformed — both cases degrade to "no extra +/// servers"). +#[must_use] +pub fn load_extra_mcp() -> BTreeMap { + let Ok(raw) = std::fs::read_to_string(EXTRA_MCP_PATH) else { + return BTreeMap::new(); + }; + serde_json::from_str(&raw).unwrap_or_else(|e| { + tracing::warn!( + path = EXTRA_MCP_PATH, + error = ?e, + "extra-mcp spec parse failed; ignoring", + ); + BTreeMap::new() + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn available_to_subagents_defaults_false() { + let stdio: ExtraMcpServer = + serde_json::from_value(serde_json::json!({"type": "stdio", "command": "/bin/foo"})) + .unwrap(); + assert!(!stdio.available_to_subagents()); + + let http: ExtraMcpServer = serde_json::from_value( + serde_json::json!({"type": "http", "url": "http://127.0.0.1:1/mcp"}), + ) + .unwrap(); + assert!(!http.available_to_subagents()); + } + + #[test] + fn available_to_subagents_true_is_read() { + let stdio: ExtraMcpServer = serde_json::from_value(serde_json::json!({ + "type": "stdio", + "command": "/bin/foo", + "availableToSubagents": true, + })) + .unwrap(); + assert!(stdio.available_to_subagents()); + } + + #[test] + fn to_json_entry_injects_state_dir_when_absent() { + let stdio: ExtraMcpServer = serde_json::from_value(serde_json::json!({ + "type": "stdio", + "command": "/bin/foo", + })) + .unwrap(); + let entry = stdio.to_json_entry(Path::new("/agents/x/state")); + assert_eq!(entry["env"]["HYPERHIVE_STATE_DIR"], "/agents/x/state"); + } + + #[test] + fn to_json_entry_respects_explicit_state_dir() { + let stdio: ExtraMcpServer = serde_json::from_value(serde_json::json!({ + "type": "stdio", + "command": "/bin/foo", + "env": {"HYPERHIVE_STATE_DIR": "/custom"}, + })) + .unwrap(); + let entry = stdio.to_json_entry(Path::new("/agents/x/state")); + assert_eq!(entry["env"]["HYPERHIVE_STATE_DIR"], "/custom"); + } + + #[test] + fn http_entry_has_no_env() { + let http: ExtraMcpServer = serde_json::from_value(serde_json::json!({ + "type": "http", + "url": "http://127.0.0.1:1/mcp", + })) + .unwrap(); + let entry = http.to_json_entry(Path::new("/agents/x/state")); + assert_eq!(entry["url"], "http://127.0.0.1:1/mcp"); + assert!(entry.get("env").is_none()); + } +} diff --git a/hive-subagent-mcp/Cargo.toml b/hive-subagent-mcp/Cargo.toml index b3315719..9c673464 100644 --- a/hive-subagent-mcp/Cargo.toml +++ b/hive-subagent-mcp/Cargo.toml @@ -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 diff --git a/hive-subagent-mcp/src/lib.rs b/hive-subagent-mcp/src/lib.rs index 180efee8..666088be 100644 --- a/hive-subagent-mcp/src/lib.rs +++ b/hive-subagent-mcp/src/lib.rs @@ -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; diff --git a/hive-subagent-mcp/src/mcp_config.rs b/hive-subagent-mcp/src/mcp_config.rs new file mode 100644 index 00000000..2f01c73b --- /dev/null +++ b/hive-subagent-mcp/src/mcp_config.rs @@ -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 { + let state_dir = crate::paths::state_dir(); + let servers: serde_json::Map = 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) +} diff --git a/hive-subagent-mcp/src/paths.rs b/hive-subagent-mcp/src/paths.rs index 8a05e334..c6140a64 100644 --- a/hive-subagent-mcp/src/paths.rs +++ b/hive-subagent-mcp/src/paths.rs @@ -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() +} diff --git a/hive-subagent-mcp/src/session.rs b/hive-subagent-mcp/src/session.rs index 79263a4b..98678a93 100644 --- a/hive-subagent-mcp/src/session.rs +++ b/hive-subagent-mcp/src/session.rs @@ -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, @@ -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![( diff --git a/nix/agent-modules/mcp.nix b/nix/agent-modules/mcp.nix index 151bbd00..fe5f44ae 100644 --- a/nix/agent-modules/mcp.nix +++ b/nix/agent-modules/mcp.nix @@ -124,6 +124,27 @@ in `mcp____` at build time. ''; }; + availableToSubagents = lib.mkOption { + type = lib.types.bool; + default = false; + description = '' + Whether a claude subagent (`mcp__subagent__start`/`continue`, + served by `hive-subagent-daemon`) also gets this MCP server. + Default `false`: a subagent runs `--strict-mcp-config` with no + ambient MCP discovery, so this is the *only* path an + `extraMcpServers` entry reaches a subagent's own claude + invocation at all — leaving it unset keeps a subagent exactly + as narrow as before this option existed (its native built-in + tools only, zero MCP servers). + + The auto-injected `bash` and `subagent` entries below stay at + this default — a subagent never gets hive-bash (it already has + claude's native `Bash` tool instead) or the ability to spawn + its own nested subagents, unless an operator opts a + *different* entry in explicitly. Set `true` on an entry (e.g. + a scraper or read-only lookup MCP) to hand it to subagents too. + ''; + }; }; } );