# The MCP tool surface: the built-in hyperhive server (persistent # streamable-http daemon), the bash-task backend daemon + its # auto-injected stdio bridge, the `extraMcpServers` option they hang # off, and the send-recipient allowlist. { pkgs, lib, config, ... }: let userName = config.hyperhive.user.name; in { options.hyperhive.allowedRecipients = lib.mkOption { type = lib.types.listOf lib.types.str; default = [ ]; example = [ "alice" "manager" ]; description = '' Names this agent is allowed to `send` to via `mcp__hyperhive__send`. Empty list (the default) means unrestricted — the agent can message any peer, the operator, or the manager. Non-empty list constrains the surface: only the listed names + the manager (always allowed) get through; anything else returns an error string to claude without touching the broker. The operator (`operator`) needs to be in the list if the agent should be able to surface output on the dashboard. Useful for sandboxing untrusted sub-agents — set `[ "manager" ]` to scope them to manager-only chatter. The manager itself is always exempt; this option only affects sub-agent `send`. ''; }; 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/*). ''; }; options.hyperhive.mcp.httpPort = lib.mkOption { type = lib.types.port; default = 8790; example = 8791; description = '' Loopback port the built-in hyperhive MCP surface is served on. HTTP is the *sole* transport for the built-in surface: a long-lived `hive-mcp-http` systemd unit runs `hive-agent-mcp --http 127.0.0.1:` and `render_claude_config` points claude at the stable `http://127.0.0.1:/mcp` URL. That URL survives the per-turn claude re-spawn (and a host-side hive-c0re restart — each tool call dials the control socket fresh), so there is no per-turn MCP re-registration race (a resumed stdio child could emit its first tool call before that turn's async `initialize`/`tools-list` completed, stranding the agent with `No such tool` — the http endpoint eliminates that). Extra MCP servers (matrix/bash) stay stdio bridges regardless. Bound loopback-only; the rmcp streamable-http transport's default `allowed_hosts` (`localhost` / `127.0.0.1` / `::1`) rejects Host headers from anywhere else, so no auth token is required for a container-local endpoint. Failure-mode note: with no stdio fallback, if `hive-mcp-http` is down claude hits a dead URL until the unit restarts (guarded by `Restart=always`, `RestartSec=3`). Intended shape: no per-turn race while up, a bounded self-healing gap while restarting. Safe as a single fixed default across all agents: each container runs in its own private network namespace (isolation is always-on — see docs/network.md), so `127.0.0.1:` is per-container-private and cannot collide across agents. Override only if a container-local service already occupies this port. Must match `mcp_config::DEFAULT_MCP_HTTP_PORT` (the harness always exports `HYPERHIVE_MCP_HTTP_PORT`, so the const is only a fallback). ''; }; config = { # Auto-inject the built-in bash MCP server — always present, every # agent needs bash tools. `lib.mkDefault` so the operator's own # agent.nix can override the entry. (The matrix sibling lives in # ./matrix.nix, gated on hyperhive.matrix.enable.) hyperhive.extraMcpServers.bash = lib.mkDefault { command = "${config.hyperhive.packages.hive-bash-mcp}/bin/hive-bash-mcp"; args = [ ]; env.HIVE_BASH_SOCKET = "/run/hive-bash/socket"; allowedTools = [ "*" ]; }; environment.etc."hyperhive/extra-mcp.json".text = builtins.toJSON config.hyperhive.extraMcpServers; environment.etc."hyperhive/send-allow.json".text = builtins.toJSON config.hyperhive.allowedRecipients; # Bash task runner daemon — long-running process that owns subprocess # monitoring + completion wake signals. Always enabled (every agent # needs bash tools). The stdio MCP bridge `hive-bash-mcp` connects # to this daemon's socket per turn. # Socket dir: /run/hive-bash/ — RuntimeDirectory keeps it on tmpfs. systemd.services.hive-bash-daemon = { description = "bash task runner daemon for hive-bash-mcp"; wantedBy = [ "multi-user.target" ]; # The daemon runs every bash task via `Command::new("bash")` and the # commands themselves (hive-forge, git, jq, …) resolve from PATH. # A standalone daemon has no inherited agent PATH, so without this # `bash` itself isn't found (spawn fails with ENOENT, the task is # marked done in 0s with no output / no .out/.err). Mirror the # harness unit's PATH: NixOS appends `/bin` to each entry → # /run/wrappers/bin (setuid sudo) + /run/current-system/sw/bin # (bash, coreutils, hive-forge, …). path = [ "/run/wrappers" "/run/current-system/sw" ]; environment = { HIVE_BASH_SOCKET = "/run/hive-bash/socket"; HIVE_CONTROL_SOCKET = "/run/hive/mcp.sock"; RUST_LOG = "info"; # HYPERHIVE_HARNESS_DIR and HYPERHIVE_STATE_DIR are already # injected via systemd.globalEnvironment by the meta flake # (set to /agents//harness and /agents//state # respectively). The daemon uses these to derive its task + # loose-ends dir paths; without them it falls back to deriving # harness/ as a sibling of state/, which produces the same # value but is less robust if the two vars ever diverge. }; serviceConfig = { ExecStart = "${config.hyperhive.packages.hive-bash-daemon}/bin/hive-bash-daemon"; SyslogIdentifier = "hive-bash-daemon"; Restart = "on-failure"; RestartSec = 3; User = userName; Group = userName; RuntimeDirectory = "hive-bash"; # Keep /run/hive-bash across restarts. With the default # `RuntimeDirectoryPreserve=no`, a post-rebuild restart races # stop-time dir cleanup against the fresh daemon's socket-dir # creation; the daemon loses, fails `mkdir /run/hive-bash` # (Permission denied, non-root in /run), and loops on # Restart=on-failure until the next container boot — i.e. the # bash daemon "doesn't come up post-rebuild". Same shape as # hive-matrix-daemon (./matrix.nix). RuntimeDirectoryPreserve = "yes"; }; }; # Persistent streamable-http MCP daemon for the built-in hyperhive # surface — the *sole* transport for that surface; always # wired. Long-lived so claude reconnects to the stable URL each turn # instead of respawning + re-registering a stdio subprocess (the # per-turn MCP registration race). It dials the control socket # (`/run/hive/mcp.sock`, the harness binaries' default) fresh on every # tool call, so a host-side hive-c0re restart is transparent. # `before = hive-ag3nt` so the URL is already listening by the time # the harness renders the first turn's config; the harness/claude also # reconnect on their own, so ordering is a latency nicety not a hard # correctness dep. systemd.services.hive-mcp-http = { description = "persistent streamable-http MCP daemon for the hyperhive surface"; wantedBy = [ "multi-user.target" ]; before = [ "hive-ag3nt.service" ]; environment.RUST_LOG = "info"; serviceConfig = { ExecStart = "${config.hyperhive.packages.hive-agent-mcp}/bin/hive-agent-mcp --http 127.0.0.1:${toString config.hyperhive.mcp.httpPort}"; SyslogIdentifier = "hive-mcp-http"; # `always` (not `on-failure`): this endpoint is load-bearing — the # sole hyperhive-MCP transport, so a down window is total # hyperhive-MCP loss with no stdio fallback and no per-turn # self-heal (the URL just stays dead). `always` also covers any # unforeseen clean-return path and restarts after a stray SIGTERM # stops it out from under the harness. Restart = "always"; RestartSec = 3; User = userName; Group = userName; }; }; }; }