feat(#2659): serve hive-bash-mcp over persistent streamable-http, drop stdio bridge

This commit is contained in:
damocles 2026-07-23 17:34:02 +02:00 committed by mara
commit c4fcf7fbf1
26 changed files with 376 additions and 574 deletions

View file

@ -158,10 +158,10 @@
# unreachable from inside a container — wrapped with
# `wireguard-tools` for `hivectl wg`). The daemon/harness/MCP bins
# the harness execs (hive-agent{,-mcp}, hive-bash-daemon,
# hive-matrix-daemon, hive-bash-mcp, hive-matrix-mcp) are wired via
# their own ExecStart/command lines in the sibling modules — they
# don't need to be on PATH too. Only these two are actually looked
# up on PATH by claude/shell code inside the container:
# hive-matrix-daemon, hive-matrix-mcp) are wired via their own
# ExecStart/command lines in the sibling modules — they don't need
# to be on PATH too. Only these two are actually looked up on PATH
# by claude/shell code inside the container:
# `hive-agent-wake` (external wake CLI, docs/turn-loop/mcp.md) and
# `hive-metric` (agent-emitted custom metrics CLI,
# docs/observability.md).

View file

@ -1,7 +1,8 @@
# 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.
# 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,
@ -42,19 +43,51 @@ in
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.str;
description = "Absolute path to the MCP server binary. Use `\${pkgs.foo}/bin/foo` or `/run/current-system/sw/bin/foo`.";
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.";
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.";
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;
@ -84,6 +117,10 @@ in
env.MATRIX_HOMESERVER = "https://matrix.example.org";
allowedTools = [ "send_message" "join_room" ];
};
bash = {
type = "http";
url = "http://127.0.0.1:8791/mcp";
};
}
'';
description = ''
@ -112,8 +149,9 @@ in
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.
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
@ -136,15 +174,48 @@ in
'';
};
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.)
# ./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 {
command = "${config.hyperhive.packages.hive-bash-mcp}/bin/hive-bash-mcp";
args = [ ];
env.HIVE_BASH_SOCKET = "/run/hive-bash/socket";
type = "http";
url = "http://127.0.0.1:${toString config.hyperhive.mcp.bashHttpPort}/mcp";
allowedTools = [ "*" ];
};
@ -154,13 +225,13 @@ in
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.
# 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 daemon for hive-bash-mcp";
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
@ -174,7 +245,6 @@ in
"/run/current-system/sw"
];
environment = {
HIVE_BASH_SOCKET = "/run/hive-bash/socket";
# 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
@ -190,22 +260,15 @@ in
# value but is less robust if the two vars ever diverge.
};
serviceConfig = {
ExecStart = "${config.hyperhive.packages.hive-bash-daemon}/bin/hive-bash-daemon";
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";
Restart = "on-failure";
# `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;
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";
};
};

View file

@ -11,7 +11,7 @@
description = ''
hyperhive package outputs consumed by the harness modules: the
per-binary daemon/CLI packages (`hive-agent`, `hive-agent-mcp`,
`hive-agent-wake`, `hive-bash-daemon`, `hive-bash-mcp`,
`hive-agent-wake`, `hive-bash-daemon`,
`hive-forge`, `hive-matrix-daemon`, `hive-matrix-mcp`,
`hive-metric`, `hive-screen-mcp`) plus the `assets`, `frontend` and
`reference-docs` trees. Wired by the flake's agent-base/ruth

View file

@ -27,8 +27,7 @@ let
hive-agent = "hyperhive in-container agent harness serve loop";
hive-agent-mcp = "hyperhive agent-surface MCP server";
hive-agent-wake = "hyperhive external wake CLI push a message into an agent's own inbox";
hive-bash-daemon = "hyperhive per-agent bash-task runner daemon";
hive-bash-mcp = "hyperhive bash-task MCP bridge";
hive-bash-daemon = "hyperhive per-agent bash-task runner daemon (serves its MCP tools directly over streamable-http)";
hive-matrix-daemon = "hyperhive per-agent matrix-sdk daemon";
hive-matrix-mcp = "hyperhive matrix MCP bridge";
hive-metric = "hyperhive agent-emitted custom metrics CLI";