Watch
0
0
Fork
You've already forked hyperhive
0

subagents: fold hive-extra-mcp into hive-agent-sock per mara's rescoping

This commit is contained in:
damocles 2026-09-13 13:54:33 +02:00 • committed by mara
commit 349f7096ba
12 changed files with 75 additions and 81 deletions

View file

@ -123,15 +123,18 @@ hand-maintained per-file tree drifts out of sync with the code.
+ manager socket (`/run/hive/mcp.sock`), the protocol an agent's harness
speaks to `hive-c0re`. Same split rationale as the two above; the shared
payload types it references stay in `hive-sh4re`.
- **`hive-agent-sock/`** — wire types for the *in-agent* socket, served by
the harness to in-container producers — matrix / bash MCP daemons and
`forge_notify` are the built-in ones, but any user-configured MCP server
can push todos here too, nothing restricts the `subsystem` set. Carries
the loose-ends-v2 **todo** ops plus
harness-local reminders. ⚠️ Distinct from
- **`hive-agent-sock/`** — what's common across the in-container "agent
plugins" (matrix/bash/subagent MCP daemons, `forge_notify`, any
user-configured MCP server). Wire types for the *in-agent* socket, served
by the harness to those producers — nothing restricts the `subsystem` set
on the todo ops, any producer can push its own. Carries the loose-ends-v2
**todo** ops plus harness-local reminders. ⚠️ Distinct from
`hive-core-agent-sock` above: **this socket never leaves the container**
and `hive-c0re` is not in the path at all — no broker round-trip, no
long-poll, no marker files.
long-poll, no marker files. Also carries `extra_mcp`: the
`hyperhive.extraMcpServers` spec + parsing, shared by `hive-agent` and
`hive-subagent-mcp` — not a socket protocol, just the natural home since
both already depend on this crate.
- **`hive-types/`** — zero-dependency (bar serde) leaf crate holding the
foundational newtypes, chiefly `Ident` (1–63 chars of `[a-z0-9-]`,
constructed only via the validating parser). Lets every wire-type crate

13
Cargo.lock generated
View file

@ -1654,7 +1654,6 @@ dependencies = [
"hive-agent-sock",
"hive-claude",
"hive-core-agent-sock",
"hive-extra-mcp",
"hive-sh4re",
"hive-sock-client",
"http-body-util",
@ -1706,6 +1705,8 @@ version = "0.1.0"
dependencies = [
"hive-sh4re",
"serde",
"serde_json",
"tracing",
]
[[package]]
@ -1806,15 +1807,6 @@ dependencies = [
"serde",
]
[[package]]
name = "hive-extra-mcp"
version = "0.1.0"
dependencies = [
"serde",
"serde_json",
"tracing",
]
[[package]]
name = "hive-forge"
version = "0.1.0"
@ -2002,7 +1994,6 @@ dependencies = [
"clap",
"hive-agent-sock",
"hive-claude",
"hive-extra-mcp",
"hive-sock-client",
"hive-types",
"rmcp",

View file

@ -7,7 +7,6 @@ members = [
"hive-core-agent-sock",
"hive-bash-mcp",
"hive-c0re",
"hive-extra-mcp",
"hive-screen-mcp",
"hive-forge",
"hive-forge-notify",
@ -86,7 +85,6 @@ 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

@ -9,4 +9,6 @@ workspace = true
[dependencies]
serde.workspace = true
serde_json.workspace = true
tracing.workspace = true
hive-sh4re.workspace = true

View file

@ -1,14 +1,15 @@
# hive-agent-sock
Wire types for the **in-agent socket** — the one the `hive-agent` harness serves
_inside_ the container to its local producers. The matrix / bash MCP daemons
and `forge_notify` are the _built-in_ producers that ship today, but any
user-configured MCP server declared in an agent's `agent.nix` can dial this
socket and push its own todos with an arbitrary `subsystem` marker — the
wire format doesn't restrict the set. Unlike the host-served sockets, this
one **never leaves the container**.
What's common across the in-container "agent plugins" — the matrix / bash /
subagent MCP daemons and `forge_notify` today, plus any user-configured MCP
server declared in an agent's `agent.nix`. Two things live here:
## What it carries
## Wire types for the in-agent socket
The socket the `hive-agent` harness serves _inside_ the container to those
local producers. Any producer can dial it and push its own todos with an
arbitrary `subsystem` marker — the wire format doesn't restrict the set.
Unlike the host-served sockets, this one **never leaves the container**.
- the loose-ends-v2 **todo** op family
- the harness-local **reminder** op family
@ -16,14 +17,23 @@ one **never leaves the container**.
More in-agent request families may be added over time — the socket is
deliberately named for the agent, not for the todos.
## Why in-container
**Why in-container**: the harness owns the todo + reminder stores locally and
signals its own turn loop directly, so `hive-c0re` is not in either path: no
broker round-trip, no long-poll, no marker files. That locality is the
point — it's also what lets these stores travel with the agent for hive
portability.
The harness owns the todo + reminder stores locally and signals its own turn
loop directly, so `hive-c0re` is not in either path: no broker round-trip, no
long-poll, no marker files. That locality is the point — it's also what lets
these stores travel with the agent for hive portability.
**Not to be confused with `hive-core-agent-sock`**: that crate is the
_host_-served core↔agent protocol on `/run/hive/mcp.sock` (the harness
talking out to `hive-c0re`). This socket is purely intra-container.
## Not to be confused with `hive-core-agent-sock`
## `extra_mcp`: the `hyperhive.extraMcpServers` spec
That crate is the _host_-served core↔agent protocol on `/run/hive/mcp.sock`
(the harness talking out to `hive-c0re`). This socket is purely intra-container.
Not a socket protocol — a shared config type. Parses
`/etc/hyperhive/extra-mcp.json` (the nix-rendered `hyperhive.extraMcpServers`
option) and renders each entry into the shape claude expects in a
`--mcp-config` blob. Shared by `hive-agent` (the main session's servers) and
`hive-subagent-mcp` (the subagent-eligible subset, gated on
`availableToSubagents`) — both already depend on this crate for the socket
types above, so a new plugin-facing shared type belongs here rather than in
a crate of its own.

View file

@ -0,0 +1,186 @@
//! 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. Lives in `hive-agent-sock`
//! rather than a crate of its own: this crate is already what's common
//! across the in-container "agent plugins" (matrix/bash/subagent MCP
//! daemons, `forge_notify`) — the todo-socket wire types above, and now
//! this — so a new plugin-facing shared type belongs here rather than in
//! a fresh single-purpose crate.
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

@ -1,23 +1,31 @@
//! Wire types for the *in-agent* socket, served by the hive-agent harness
//! to the in-container producers. Matrix / bash MCP daemons and
//! `forge_notify` are the built-in ones today, but `subsystem` on the todo
//! ops below is a plain string, not a closed set: any user-configured MCP
//! server declared in an agent's `agent.nix` can dial this socket and push
//! its own todos the same way. Carries the loose-ends-v2 *todo* op family
//! plus the harness-local *reminder* op family; more in-agent request
//! families may be added over time (the socket is deliberately named for
//! the agent, not the todos).
//! Rescoped beyond its original name: what's common across the in-container
//! "agent plugins" (matrix/bash/subagent MCP daemons, `forge_notify`, and any
//! user-configured MCP server) — not just the in-agent socket wire types.
//!
//! **Wire types for the *in-agent* socket**, served by the hive-agent harness
//! to those producers. `subsystem` on the todo ops below is a plain string,
//! not a closed set: any user-configured MCP server declared in an agent's
//! `agent.nix` can dial this socket and push its own todos the same way.
//! Carries the loose-ends-v2 *todo* op family plus the harness-local
//! *reminder* op family; more in-agent request families may be added over
//! time (the socket is deliberately named for the agent, not the todos).
//!
//! Distinct from `hive-core-agent-sock`, the *host*-served core↔agent
//! protocol on `/run/hive/mcp.sock`: this socket never leaves the
//! container. The harness owns the todo + reminder stores locally and
//! signals its own turn loop directly, so hive-c0re is not in either path —
//! no broker round-trip, no long-poll, no marker files.
//!
//! **`extra_mcp`**: the `hyperhive.extraMcpServers` spec + parsing, shared by
//! `hive-agent` (the main session's `--mcp-config`) and `hive-subagent-mcp`
//! (the subagent-eligible subset) — not a socket protocol at all, but this
//! crate is the natural home since both consumers already depend on it.
use serde::{Deserialize, Serialize};
use hive_sh4re::inbox::LooseEnd;
pub mod extra_mcp;
pub mod paths;
/// In-container path of the harness-served in-agent socket. The harness

View file

@ -23,7 +23,6 @@ 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 hive_extra_mcp::load_extra_mcp() {
for (server, spec) in hive_agent_sock::extra_mcp::load_extra_mcp() {
if server == SERVER_NAME || !extra_server_enabled(&server, groups) {
continue;
}
@ -300,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 hive_extra_mcp::load_extra_mcp() {
for (name, spec) in hive_agent_sock::extra_mcp::load_extra_mcp() {
if name == SERVER_NAME {
tracing::warn!(
"extra MCP server name `{SERVER_NAME}` collides with the built-in surface; ignoring",

View file

@ -1,18 +0,0 @@
[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.

View file

@ -13,7 +13,6 @@ 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

@ -1,6 +1,6 @@
//! 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
//! (see `hive_agent_sock::extra_mcp::ExtraMcpServer::available_to_subagents`) ever reach
//! a subagent's claude invocation. Everything else — the built-in hyperhive
//! surface (todos/messaging), the automatically injected `bash` and `subagent`
//! entries — stays unreachable by construction: none of those default to
@ -24,11 +24,12 @@ const CONFIG_FILE: &str = "subagent-mcp-config.json";
#[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();
let servers: serde_json::Map<String, serde_json::Value> =
hive_agent_sock::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;
}