From 7d6d8e96c151366a93326f715268a4c5ad671fd7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?m=C3=BCde?= Date: Sat, 16 May 2026 02:10:11 +0200 Subject: [PATCH] per-agent extra MCP servers via hyperhive.extraMcpServers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit new NixOS option in harness-base.nix: hyperhive.extraMcpServers. = { command = "/path/to/server"; args = [ ... ]; env = { KEY = "value"; }; allowedTools = [ "send_message" "join_room" ]; # or ["*"] }; declared as attrsOf submodule so agents stack arbitrarily many. the module writes the whole map as JSON to /etc/hyperhive/extra-mcp.json at activation; the harness reads that file in mcp::render_claude_config and merges each entry into the rendered --mcp-config under its own mcpServers. block. allowed_mcp_tools(flavor) extends the --allowedTools arg with mcp____ for every entry — "*" (the default) becomes mcp____* so every tool from that server is auto-approved, or pass a concrete list to tighten. collision guard: an extra server keyed "hyperhive" is dropped with a warn-log so user config can't shadow the built-in surface. malformed JSON / missing file fall back to "no extras" silently. prompt note added: agents see "(some agents only) extra MCP tools surfaced as mcp____" and learn they're declared via agent.nix. retires the matching TODO under Per-agent extension. matrix-chat agents + bitburner-agent migration unblocked. --- TODO.md | 15 ------ hive-ag3nt/prompts/agent.md | 1 + hive-ag3nt/src/mcp.rs | 94 ++++++++++++++++++++++++++++++---- nix/templates/harness-base.nix | 59 ++++++++++++++++++++- 4 files changed, 142 insertions(+), 27 deletions(-) diff --git a/TODO.md b/TODO.md index a9ee2be..e38bcf8 100644 --- a/TODO.md +++ b/TODO.md @@ -47,21 +47,6 @@ Pick anything from here when relevant. Cross-cutting design notes live in claude-code's `--allowedTools` extended grammar. Likely lives in `agent.nix` so each agent can scope its own shell surface. -## Per-agent extension - -- **Custom per-agent MCP tools.** Today every sub-agent gets the - same fixed MCP surface (`send`, `recv`). To move bitburner-agent - (and anything else with rich domain tooling) into hyperhive, an - agent needs a way to ship its own tools alongside hyperhive's. - Sketch: `agent.nix` declares a list of extra MCP servers - (command + args + env), each registered into the agent's - `--mcp-config` blob at flake-render time. The harness MCP server - remains the hyperhive surface; new servers slot in as additional - entries under `mcpServers.` so claude sees them as - `mcp____`. Per-agent tool whitelist (`allowedTools`) - derived from the same config so the operator stays in control of - what's exposed. - ## Operational hygiene (post-meta-flake) - **Tag retention.** Every approval mints up to 5 tags in diff --git a/hive-ag3nt/prompts/agent.md b/hive-ag3nt/prompts/agent.md index 4647e4e..b3d4d20 100644 --- a/hive-ag3nt/prompts/agent.md +++ b/hive-ag3nt/prompts/agent.md @@ -4,6 +4,7 @@ Tools (hyperhive surface): - `mcp__hyperhive__recv(wait_seconds?)` — drain one more message from your inbox (returns `(empty)` if nothing pending after the wait). Without `wait_seconds` it long-polls 30s. To **wait** for work when you have nothing else useful to do this turn, call with a long wait (e.g. `wait_seconds: 180`, the max) — you'll be woken instantly when a message arrives, otherwise return after the timeout. That is strictly better than calling `recv` repeatedly with short waits: lower latency on new work, fewer turns, no busy-loop. Never use a fixed `sleep` shell command for the same purpose. - `mcp__hyperhive__send(to, body)` — message a peer (by their name) or the operator (recipient `operator`, surfaces in the dashboard). +- (some agents only) **extra MCP tools** surfaced as `mcp____` — these are agent-specific (matrix client, scraper, db connector, etc.) declared in your `agent.nix` under `hyperhive.extraMcpServers`. Treat them as first-class tools alongside the hyperhive surface; the operator already auto-approved them at deploy time. - `mcp__hyperhive__ask_operator(question, options?, multi?, ttl_seconds?)` — surface a question to the human operator on the dashboard. Returns immediately with a question id — do NOT wait inline. When the operator answers, a system message with event `operator_answered { id, question, answer }` lands in your inbox; handle it on a future turn. Use this for clarifications, permission for risky actions, or choice between options. `options` is advisory: a short fixed-choice list when applicable, otherwise leave empty for free text. `multi: true` lets the operator pick multiple (checkboxes), answer comes back comma-joined. `ttl_seconds` auto-cancels with answer `[expired]` when the decision becomes moot. Need new packages, env vars, or other NixOS config for yourself? You can't edit your own config directly — message the manager (recipient `manager`) describing what you need + why. The manager evaluates the request (it doesn't rubber-stamp), edits `/agents/{label}/config/agent.nix` on your behalf, commits, and submits an approval that the operator can accept on the dashboard; on approve hive-c0re rebuilds your container with the new config. diff --git a/hive-ag3nt/src/mcp.rs b/hive-ag3nt/src/mcp.rs index ab0e749..d8831b4 100644 --- a/hive-ag3nt/src/mcp.rs +++ b/hive-ag3nt/src/mcp.rs @@ -580,10 +580,24 @@ pub fn allowed_mcp_tools(flavor: Flavor) -> Vec { "ask_operator", ], }; - names + let mut out: Vec = names .iter() .map(|t| format!("mcp__{SERVER_NAME}__{t}")) - .collect() + .collect(); + // Extra MCP servers declared via `hyperhive.extraMcpServers` in + // the agent's NixOS config. Each entry maps its `allowedTools` + // pattern list to `mcp____` 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() { + if server == SERVER_NAME { + continue; + } + for pat in spec.allowed_tools { + out.push(format!("mcp__{server}__{pat}")); + } + } + out } /// Combined allow-list passed to `--allowedTools` (auto-approve) — covers @@ -605,20 +619,78 @@ pub fn builtin_tools_arg() -> String { ALLOWED_BUILTIN_TOOLS.join(",") } +/// Where the NixOS module writes the per-agent extra-MCP spec (see +/// `nix/templates/harness-base.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"; + +#[derive(Debug, serde::Deserialize)] +struct ExtraMcpServer { + command: String, + #[serde(default)] + args: Vec, + #[serde(default)] + env: std::collections::BTreeMap, + #[serde(default = "default_allowed_tools")] + #[serde(rename = "allowedTools")] + allowed_tools: Vec, +} + +fn default_allowed_tools() -> Vec { + vec!["*".to_owned()] +} + +/// Read + parse the extra-MCP spec. Returns an empty map on missing / +/// unparsable file (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 `. /// `agent_binary` is the path (or PATH-resolvable name) of the `hive-ag3nt` /// executable; `socket` is the hyperhive per-agent socket bind-mounted into -/// the container (forwarded to the child as `--socket `). +/// the container (forwarded to the child as `--socket `). Merges in +/// any extra MCP servers declared via `hyperhive.extraMcpServers` in the +/// agent's NixOS config. #[must_use] pub fn render_claude_config(agent_binary: &str, socket: &std::path::Path) -> String { - let config = serde_json::json!({ - "mcpServers": { - SERVER_NAME: { - "command": agent_binary, - "args": ["--socket", socket.display().to_string(), "mcp"], - "env": {} - } + let mut servers = serde_json::Map::new(); + servers.insert( + SERVER_NAME.to_owned(), + serde_json::json!({ + "command": agent_binary, + "args": ["--socket", socket.display().to_string(), "mcp"], + "env": {} + }), + ); + for (name, spec) in load_extra_mcp() { + if name == SERVER_NAME { + tracing::warn!( + "extra MCP server name `{SERVER_NAME}` collides with the built-in surface; ignoring", + ); + continue; } - }); + servers.insert( + name, + serde_json::json!({ + "command": spec.command, + "args": spec.args, + "env": spec.env, + }), + ); + } + let config = serde_json::json!({ "mcpServers": servers }); serde_json::to_string_pretty(&config).unwrap_or_else(|_| "{}".into()) } diff --git a/nix/templates/harness-base.nix b/nix/templates/harness-base.nix index 826b57e..c2dab51 100644 --- a/nix/templates/harness-base.nix +++ b/nix/templates/harness-base.nix @@ -1,10 +1,67 @@ -{ pkgs, lib, ... }: +{ pkgs, lib, config, ... }: { # Shared scaffolding for any hyperhive harness container — both # sub-agents (`agent-base.nix`) and the manager (`manager.nix`) extend # this. The systemd service that actually runs the harness binary # differs per role and lives in the child module. + options.hyperhive.extraMcpServers = lib.mkOption { + type = lib.types.attrsOf (lib.types.submodule { + options = { + command = lib.mkOption { + type = lib.types.str; + description = "Absolute path to the MCP server binary. Use `\${pkgs.foo}/bin/foo` or `/run/current-system/sw/bin/foo`."; + }; + args = lib.mkOption { + type = lib.types.listOf lib.types.str; + default = [ ]; + description = "Args passed to the MCP server binary."; + }; + env = lib.mkOption { + type = lib.types.attrsOf lib.types.str; + default = { }; + description = "Environment variables for the MCP server child process."; + }; + allowedTools = lib.mkOption { + type = lib.types.listOf lib.types.str; + default = [ "*" ]; + example = [ "send_message" "join_room" ]; + description = '' + Tool names this MCP server is auto-approved to call via + `--allowedTools`. Single entry `"*"` (the default) means + "every tool from this server" — convenient but trusting. + Tighten to a specific list when you only want a subset. + Names are bare (e.g. `send_message`); the harness prepends + `mcp____` at build time. + ''; + }; + }; + }); + default = { }; + example = lib.literalExpression '' + { + matrix = { + command = "/run/current-system/sw/bin/mcp-matrix"; + args = [ "--config" "/state/matrix.toml" ]; + env.MATRIX_HOMESERVER = "https://matrix.example.org"; + allowedTools = [ "send_message" "join_room" ]; + }; + } + ''; + description = '' + Extra MCP servers claude sees alongside the hyperhive tool surface. + Keys are the server names (claude addresses tools as + `mcp____`). Rendered to `/etc/hyperhive/extra-mcp.json` + at activation time; the harness reads that file at boot and merges + it into `--mcp-config` + `--allowedTools`. Take effect on the + agent's next harness restart (no operator approval needed beyond + whatever brought the new agent.nix into deployed/*). + ''; + }; + + environment.etc."hyperhive/extra-mcp.json".text = + builtins.toJSON config.hyperhive.extraMcpServers; + boot.isNspawnContainer = true; # `claude-code` is unfree. Each per-agent container's nixosConfiguration