Compare commits
14 changed files with 119 additions and 385 deletions
17
CLAUDE.md
17
CLAUDE.md
|
|
@ -123,18 +123,15 @@ hand-maintained per-file tree drifts out of sync with the code.
|
||||||
+ manager socket (`/run/hive/mcp.sock`), the protocol an agent's harness
|
+ 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
|
speaks to `hive-c0re`. Same split rationale as the two above; the shared
|
||||||
payload types it references stay in `hive-sh4re`.
|
payload types it references stay in `hive-sh4re`.
|
||||||
- **`hive-agent-sock/`** — what's common across the in-container "agent
|
- **`hive-agent-sock/`** — wire types for the *in-agent* socket, served by
|
||||||
plugins" (matrix/bash/subagent MCP daemons, `forge_notify`, any
|
the harness to in-container producers — matrix / bash MCP daemons and
|
||||||
user-configured MCP server). Wire types for the *in-agent* socket, served
|
`forge_notify` are the built-in ones, but any user-configured MCP server
|
||||||
by the harness to those producers — nothing restricts the `subsystem` set
|
can push todos here too, nothing restricts the `subsystem` set. Carries
|
||||||
on the todo ops, any producer can push its own. Carries the loose-ends-v2
|
the loose-ends-v2 **todo** ops plus
|
||||||
**todo** ops plus harness-local reminders. ⚠️ Distinct from
|
harness-local reminders. ⚠️ Distinct from
|
||||||
`hive-core-agent-sock` above: **this socket never leaves the container**
|
`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
|
and `hive-c0re` is not in the path at all — no broker round-trip, no
|
||||||
long-poll, no marker files. Also carries `extra_mcp`: the
|
long-poll, no marker files.
|
||||||
`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
|
- **`hive-types/`** — zero-dependency (bar serde) leaf crate holding the
|
||||||
foundational newtypes, chiefly `Ident` (1–63 chars of `[a-z0-9-]`,
|
foundational newtypes, chiefly `Ident` (1–63 chars of `[a-z0-9-]`,
|
||||||
constructed only via the validating parser). Lets every wire-type crate
|
constructed only via the validating parser). Lets every wire-type crate
|
||||||
|
|
|
||||||
2
Cargo.lock
generated
2
Cargo.lock
generated
|
|
@ -1705,8 +1705,6 @@ version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"hive-sh4re",
|
"hive-sh4re",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
|
||||||
"tracing",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
|
||||||
|
|
@ -54,21 +54,3 @@ agent that needs a stable or non-default port.
|
||||||
|
|
||||||
Own systemd unit, defined alongside the other per-agent MCP daemons in
|
Own systemd unit, defined alongside the other per-agent MCP daemons in
|
||||||
`nix/agent-modules/mcp.nix`.
|
`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 automatically 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.
|
|
||||||
|
|
|
||||||
|
|
@ -13,10 +13,7 @@ entry (`type = "stdio" | "http"`, default `"stdio"`): `matrix` stays a
|
||||||
stdio bridge, `bash` runs its own persistent streamable-http listener
|
stdio bridge, `bash` runs its own persistent streamable-http listener
|
||||||
(`hive-bash-daemon`) — same reasoning as the built-in surface. The
|
(`hive-bash-daemon`) — same reasoning as the built-in surface. The
|
||||||
server name is `hyperhive`, so the tools land in claude as
|
server name is `hyperhive`, so the tools land in claude as
|
||||||
`mcp__hyperhive__<tool>`. Each entry also has an `availableToSubagents`
|
`mcp__hyperhive__<tool>`.
|
||||||
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
|
Tool groups (`HIVE_TOOL_GROUPS`) gate tool access. The default
|
||||||
preset (`AGENT_DEFAULT`) includes `messaging`, `meta`, `inbox`, and
|
preset (`AGENT_DEFAULT`) includes `messaging`, `meta`, `inbox`, and
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,4 @@ workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
serde_json.workspace = true
|
|
||||||
tracing.workspace = true
|
|
||||||
hive-sh4re.workspace = true
|
hive-sh4re.workspace = true
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,14 @@
|
||||||
# hive-agent-sock
|
# hive-agent-sock
|
||||||
|
|
||||||
What's common across the in-container "agent plugins" — the matrix / bash /
|
Wire types for the **in-agent socket** — the one the `hive-agent` harness serves
|
||||||
subagent MCP daemons and `forge_notify` today, plus any user-configured MCP
|
_inside_ the container to its local producers. The matrix / bash MCP daemons
|
||||||
server declared in an agent's `agent.nix`. Two things live here:
|
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**.
|
||||||
|
|
||||||
## Wire types for the in-agent socket
|
## What it carries
|
||||||
|
|
||||||
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 loose-ends-v2 **todo** op family
|
||||||
- the harness-local **reminder** op family
|
- the harness-local **reminder** op family
|
||||||
|
|
@ -17,23 +16,14 @@ Unlike the host-served sockets, this one **never leaves the container**.
|
||||||
More in-agent request families may be added over time — the socket is
|
More in-agent request families may be added over time — the socket is
|
||||||
deliberately named for the agent, not for the todos.
|
deliberately named for the agent, not for the todos.
|
||||||
|
|
||||||
**Why in-container**: the harness owns the todo + reminder stores locally and
|
## Why in-container
|
||||||
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
|
The harness owns the todo + reminder stores locally and signals its own turn
|
||||||
_host_-served core↔agent protocol on `/run/hive/mcp.sock` (the harness
|
loop directly, so `hive-c0re` is not in either path: no broker round-trip, no
|
||||||
talking out to `hive-c0re`). This socket is purely intra-container.
|
long-poll, no marker files. That locality is the point — it's also what lets
|
||||||
|
these stores travel with the agent for hive portability.
|
||||||
|
|
||||||
## `extra_mcp`: the `hyperhive.extraMcpServers` spec
|
## Not to be confused with `hive-core-agent-sock`
|
||||||
|
|
||||||
Not a socket protocol — a shared config type. Parses
|
That crate is the _host_-served core↔agent protocol on `/run/hive/mcp.sock`
|
||||||
`/etc/hyperhive/extra-mcp.json` (the nix-rendered `hyperhive.extraMcpServers`
|
(the harness talking out to `hive-c0re`). This socket is purely intra-container.
|
||||||
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.
|
|
||||||
|
|
|
||||||
|
|
@ -1,186 +0,0 @@
|
||||||
//! 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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,31 +1,23 @@
|
||||||
//! Rescoped beyond its original name: what's common across the in-container
|
//! Wire types for the *in-agent* socket, served by the hive-agent harness
|
||||||
//! "agent plugins" (matrix/bash/subagent MCP daemons, `forge_notify`, and any
|
//! to the in-container producers. Matrix / bash MCP daemons and
|
||||||
//! user-configured MCP server) — not just the in-agent socket wire types.
|
//! `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
|
||||||
//! **Wire types for the *in-agent* socket**, served by the hive-agent harness
|
//! server declared in an agent's `agent.nix` can dial this socket and push
|
||||||
//! to those producers. `subsystem` on the todo ops below is a plain string,
|
//! its own todos the same way. Carries the loose-ends-v2 *todo* op family
|
||||||
//! not a closed set: any user-configured MCP server declared in an agent's
|
//! plus the harness-local *reminder* op family; more in-agent request
|
||||||
//! `agent.nix` can dial this socket and push its own todos the same way.
|
//! families may be added over time (the socket is deliberately named for
|
||||||
//! Carries the loose-ends-v2 *todo* op family plus the harness-local
|
//! the agent, not the todos).
|
||||||
//! *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
|
//! Distinct from `hive-core-agent-sock`, the *host*-served core↔agent
|
||||||
//! protocol on `/run/hive/mcp.sock`: this socket never leaves the
|
//! protocol on `/run/hive/mcp.sock`: this socket never leaves the
|
||||||
//! container. The harness owns the todo + reminder stores locally and
|
//! container. The harness owns the todo + reminder stores locally and
|
||||||
//! signals its own turn loop directly, so hive-c0re is not in either path —
|
//! signals its own turn loop directly, so hive-c0re is not in either path —
|
||||||
//! no broker round-trip, no long-poll, no marker files.
|
//! 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 serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use hive_sh4re::inbox::LooseEnd;
|
use hive_sh4re::inbox::LooseEnd;
|
||||||
|
|
||||||
pub mod extra_mcp;
|
|
||||||
pub mod paths;
|
pub mod paths;
|
||||||
|
|
||||||
/// In-container path of the harness-served in-agent socket. The harness
|
/// In-container path of the harness-served in-agent socket. The harness
|
||||||
|
|
|
||||||
|
|
@ -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
|
// pattern list to `mcp__<server>__<pattern>` so claude can call
|
||||||
// them without per-tool operator approval. `["*"]` (the default)
|
// them without per-tool operator approval. `["*"]` (the default)
|
||||||
// expands to `mcp__<server>__*` — every tool from that server.
|
// expands to `mcp__<server>__*` — every tool from that server.
|
||||||
for (server, spec) in hive_agent_sock::extra_mcp::load_extra_mcp() {
|
for (server, spec) in load_extra_mcp() {
|
||||||
if server == SERVER_NAME || !extra_server_enabled(&server, groups) {
|
if server == SERVER_NAME || !extra_server_enabled(&server, groups) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
@ -241,6 +241,70 @@ pub fn builtin_tools_arg() -> String {
|
||||||
tools.join(",")
|
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>`.
|
/// Render the MCP config blob claude reads from `--mcp-config <path>`.
|
||||||
/// The built-in `hyperhive` surface is an HTTP entry pointing at the
|
/// The built-in `hyperhive` surface is an HTTP entry pointing at the
|
||||||
/// persistent `hive-mcp-http` daemon (see [`DEFAULT_MCP_HTTP_PORT`]); there
|
/// persistent `hive-mcp-http` daemon (see [`DEFAULT_MCP_HTTP_PORT`]); there
|
||||||
|
|
@ -300,7 +364,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 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).
|
// agent could call it directly (there is no later enforcement point).
|
||||||
let groups = effective_tool_groups();
|
let groups = effective_tool_groups();
|
||||||
for (name, spec) in hive_agent_sock::extra_mcp::load_extra_mcp() {
|
for (name, spec) in load_extra_mcp() {
|
||||||
if name == SERVER_NAME {
|
if name == SERVER_NAME {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
"extra MCP server name `{SERVER_NAME}` collides with the built-in surface; ignoring",
|
"extra MCP server name `{SERVER_NAME}` collides with the built-in surface; ignoring",
|
||||||
|
|
@ -314,7 +378,20 @@ fn build_mcp_servers() -> serde_json::Map<String, serde_json::Value> {
|
||||||
);
|
);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
servers.insert(name, spec.to_json_entry(&state_dir));
|
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
|
servers
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,5 @@
|
||||||
//! in-memory `name -> Cancel` map, live only as long as the process is.
|
//! in-memory `name -> Cancel` map, live only as long as the process is.
|
||||||
|
|
||||||
pub mod mcp;
|
pub mod mcp;
|
||||||
pub mod mcp_config;
|
|
||||||
pub mod paths;
|
pub mod paths;
|
||||||
pub mod session;
|
pub mod session;
|
||||||
|
|
|
||||||
|
|
@ -1,57 +0,0 @@
|
||||||
//! Builds a subagent's own filtered `--mcp-config`: only
|
|
||||||
//! `hyperhive.extraMcpServers` entries with `availableToSubagents = true`
|
|
||||||
//! (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
|
|
||||||
//! 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_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;
|
|
||||||
}
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
//! The per-agent paths this daemon needs.
|
//! The one per-agent path this daemon needs.
|
||||||
|
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
|
@ -13,28 +13,3 @@ pub fn agent_socket() -> PathBuf {
|
||||||
PathBuf::from,
|
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()
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -185,14 +185,8 @@ fn subagent_otel_attrs(name: &str) -> String {
|
||||||
/// instructions. `dir`, when given, becomes `Config::cwd` (e.g. a worktree
|
/// instructions. `dir`, when given, becomes `Config::cwd` (e.g. a worktree
|
||||||
/// the caller already prepared); `None` inherits this daemon's own working
|
/// the caller already prepared); `None` inherits this daemon's own working
|
||||||
/// directory, same as before this field existed. Always
|
/// directory, same as before this field existed. Always
|
||||||
/// `--dangerously-skip-permissions --strict-mcp-config` — the safety
|
/// `--dangerously-skip-permissions --strict-mcp-config` (no `--mcp-config`
|
||||||
/// property is `strict_mcp_config: true` with no ambient MCP discovery, not
|
/// override — a safety property, not a knob).
|
||||||
/// 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(
|
fn build_config(
|
||||||
name: &str,
|
name: &str,
|
||||||
model: Option<String>,
|
model: Option<String>,
|
||||||
|
|
@ -207,7 +201,6 @@ fn build_config(
|
||||||
Config {
|
Config {
|
||||||
model,
|
model,
|
||||||
cwd: dir.map(PathBuf::from),
|
cwd: dir.map(PathBuf::from),
|
||||||
mcp_config: crate::mcp_config::build(),
|
|
||||||
strict_mcp_config: true,
|
strict_mcp_config: true,
|
||||||
extra_args,
|
extra_args,
|
||||||
env: vec![(
|
env: vec![(
|
||||||
|
|
|
||||||
|
|
@ -124,27 +124,6 @@ in
|
||||||
`mcp__<server-key>__` at build time.
|
`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 automatically 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.
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue