# The MCP tool surface: the built-in hyperhive server (persistent # streamable-http daemon), the bash-task backend daemon (also a # persistent streamable-http MCP server, auto-injected into # `extraMcpServers`), the `extraMcpServers` option itself (stdio or http, # per entry), 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 = { type = lib.mkOption { type = lib.types.enum [ "stdio" "http" ]; default = "stdio"; description = '' Transport for this MCP server. `"stdio"` (the default) spawns `command` as a fresh child process every turn, talking JSON-RPC over its stdin/stdout — existing entries need zero changes to keep this behaviour. `"http"` points claude at a long-lived streamable-http `url` instead: no per-turn spawn, no re-registration race, same shape as the built-in hyperhive surface (`hive-mcp-http`) — use this for a server backed by an always-on daemon. `command`/`args`/`env` only apply to `"stdio"`; `url` only to `"http"`. ''; }; command = lib.mkOption { type = lib.types.nullOr lib.types.str; default = null; description = '' Absolute path to the MCP server binary. Use `''${pkgs.foo}/bin/foo` or `/run/current-system/sw/bin/foo`. Required when `type = "stdio"`; ignored (leave `null`) for `"http"`. ''; }; args = lib.mkOption { type = lib.types.listOf lib.types.str; default = [ ]; description = "Args passed to the MCP server binary. `\"stdio\"` only."; }; env = lib.mkOption { type = lib.types.attrsOf lib.types.str; default = { }; description = "Environment variables for the MCP server child process. `\"stdio\"` only."; }; url = lib.mkOption { type = lib.types.nullOr lib.types.str; default = null; description = '' Streamable-http URL (e.g. `http://127.0.0.1:8791/mcp`) of the always-on daemon serving this MCP surface. Required when `type = "http"`; ignored (leave `null`) for `"stdio"`. ''; }; 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" ]; }; bash = { type = "http"; url = "http://127.0.0.1:8791/mcp"; }; } ''; 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/*). The `bash` entry above is illustrative only — it's auto-injected below (`hyperhive.extraMcpServers.bash` via `lib.mkDefault`) already tracking `hyperhive.mcp.bashHttpPort`, so overriding it directly with a hardcoded port (as shown) is unusual and will drift if `bashHttpPort` is changed separately; bump `bashHttpPort` instead. ''; }; 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). `matrix` stays a stdio bridge; `bash` runs its own persistent http listener (see `hyperhive.mcp.bashHttpPort`). 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). ''; }; options.hyperhive.mcp.bashHttpPort = lib.mkOption { type = lib.types.port; default = 8791; example = 8792; description = '' Loopback port `hive-bash-daemon` serves its MCP tools (`run`/`status`/`kill`) on. Same shape as `hyperhive.mcp.httpPort` for the built-in surface: HTTP is the *sole* transport (no stdio bridge — the daemon that owns the subprocess runner serves the MCP tools directly in-process), `Restart = "always"` keeps the listener self-healing, and loopback-only binding means no auth token is needed (same `allowed_hosts` reasoning as `hyperhive.mcp.httpPort`). Safe as a single fixed default across all agents (private per-container network namespace — see docs/network.md). ''; }; config = { # Assert the transport-specific required field is actually set — # `command`/`url` are both `nullOr` so the submodule schema stays # backward-compatible for existing stdio entries, but a `null` in the # field the chosen `type` actually needs is a config mistake, not a # valid "unset". assertions = lib.mapAttrsToList (name: spec: { assertion = spec.type != "stdio" || spec.command != null; message = "hyperhive.extraMcpServers.${name}: type = \"stdio\" requires `command` to be set"; }) config.hyperhive.extraMcpServers ++ lib.mapAttrsToList (name: spec: { assertion = spec.type != "http" || spec.url != null; message = "hyperhive.extraMcpServers.${name}: type = \"http\" requires `url` to be set"; }) config.hyperhive.extraMcpServers; # 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.) `hive-bash-daemon` # serves its MCP tools directly over streamable-http (no stdio bridge, # no round-trip socket) — see the `hive-bash-daemon` service below. hyperhive.extraMcpServers.bash = lib.mkDefault { type = "http"; url = "http://127.0.0.1:${toString config.hyperhive.mcp.bashHttpPort}/mcp"; 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, and serves the MCP tools # (`run`/`status`/`kill`) directly over streamable-http on # `hyperhive.mcp.bashHttpPort` — no stdio bridge, no per-turn spawn. systemd.services.hive-bash-daemon = { description = "bash task runner + MCP daemon for hive-bash"; wantedBy = [ "multi-user.target" ]; before = [ "hive-agent.service" ]; # 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 = { # In-agent todo socket the harness serves (loose-ends v2): the # runner pushes bash-task todos here (upsert while active, keyless # 'done' on completion) instead of firing a c0re wake. Must match # the harness's HIVE_AGENT_SOCKET (agent-service.nix). HIVE_AGENT_SOCKET = "/run/hive-agent/${userName}/agent.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; `hive_agent_sock::paths::harness_dir` # panics loudly if HYPERHIVE_HARNESS_DIR is unset rather than # deriving a fallback, since every service here always gets it. }; serviceConfig = { ExecStart = "${config.hyperhive.packages.hive-bash-daemon}/bin/hive-bash-daemon --http 127.0.0.1:${toString config.hyperhive.mcp.bashHttpPort}"; SyslogIdentifier = "hive-bash-daemon"; # `always` (not `on-failure`): since the MCP tools are served # in-process now, a down window is total loss of bash tools with # no stdio fallback — same reasoning as `hive-mcp-http` below. Restart = "always"; RestartSec = 3; User = userName; Group = userName; }; }; # 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-agent` 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-agent.service" ]; environment.RUST_LOG = "info"; # In-agent todo socket the harness serves (loose-ends v2): the # `get_loose_ends` handler dials it to merge this agent's local todos # with the static loose-ends from hive-c0re. environment.HIVE_AGENT_SOCKET = "/run/hive-agent/${userName}/agent.sock"; 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; }; }; }; }