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

11
Cargo.lock generated
View file

@ -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",

View file

@ -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" }

View file

@ -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.<name>.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.

View file

@ -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__<tool>`.
`mcp__hyperhive__<tool>`. 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

View file

@ -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

View file

@ -184,7 +184,7 @@ pub fn allowed_mcp_tools(groups: &[hive_sh4re::permissions::ToolGroup]) -> Vec<S
// pattern list to `mcp__<server>__<pattern>` so claude can call
// them without per-tool operator approval. `["*"]` (the default)
// expands to `mcp__<server>__*` — 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.<key>` block in the rendered claude config + a
/// `mcp__<key>__<tool>` 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<String>,
#[serde(default)]
env: std::collections::BTreeMap<String, String>,
#[serde(default = "default_allowed_tools")]
#[serde(rename = "allowedTools")]
allowed_tools: Vec<String>,
},
Http {
url: String,
#[serde(default = "default_allowed_tools")]
#[serde(rename = "allowedTools")]
allowed_tools: Vec<String>,
},
}
impl ExtraMcpServer {
fn allowed_tools(&self) -> &[String] {
match self {
Self::Stdio { allowed_tools, .. } | Self::Http { allowed_tools, .. } => allowed_tools,
}
}
}
fn default_allowed_tools() -> Vec<String> {
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<String, ExtraMcpServer> {
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 <path>`.
/// 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<String, serde_json::Value> {
// 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<String, serde_json::Value> {
);
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
}

18
hive-extra-mcp/Cargo.toml Normal file
View file

@ -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.

185
hive-extra-mcp/src/lib.rs Normal file
View file

@ -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.<key>` 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<String>,
#[serde(default)]
env: BTreeMap<String, String>,
#[serde(default = "default_allowed_tools", rename = "allowedTools")]
allowed_tools: Vec<String>,
#[serde(default, rename = "availableToSubagents")]
available_to_subagents: bool,
},
Http {
url: String,
#[serde(default = "default_allowed_tools", rename = "allowedTools")]
allowed_tools: Vec<String>,
#[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.<name>.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.<name>` 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<String> {
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<String, ExtraMcpServer> {
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());
}
}

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![(

View file

@ -124,6 +124,27 @@ in
`mcp__<server-key>__` 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.
'';
};
};
}
);