per-agent extra MCP servers via hyperhive.extraMcpServers
new NixOS option in harness-base.nix:
hyperhive.extraMcpServers.<key> = {
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.<key>
block. allowed_mcp_tools(flavor) extends the --allowedTools
arg with mcp__<key>__<pattern> for every entry — "*" (the
default) becomes mcp__<key>__* 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__<server>__<tool>" and learn they're
declared via agent.nix. retires the matching TODO under
Per-agent extension. matrix-chat agents + bitburner-agent
migration unblocked.
This commit is contained in:
parent
50ef806266
commit
7d6d8e96c1
4 changed files with 142 additions and 27 deletions
15
TODO.md
15
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
|
claude-code's `--allowedTools` extended grammar. Likely lives in
|
||||||
`agent.nix` so each agent can scope its own shell surface.
|
`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.<name>` so claude sees them as
|
|
||||||
`mcp__<name>__<tool>`. 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)
|
## Operational hygiene (post-meta-flake)
|
||||||
|
|
||||||
- **Tag retention.** Every approval mints up to 5 tags in
|
- **Tag retention.** Every approval mints up to 5 tags in
|
||||||
|
|
|
||||||
|
|
@ -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__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).
|
- `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__<server>__<tool>` — 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.
|
- `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.
|
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.
|
||||||
|
|
|
||||||
|
|
@ -580,10 +580,24 @@ pub fn allowed_mcp_tools(flavor: Flavor) -> Vec<String> {
|
||||||
"ask_operator",
|
"ask_operator",
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
names
|
let mut out: Vec<String> = names
|
||||||
.iter()
|
.iter()
|
||||||
.map(|t| format!("mcp__{SERVER_NAME}__{t}"))
|
.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__<server>__<pattern>` so claude can call
|
||||||
|
// them without per-tool operator approval. `["*"]` (the default)
|
||||||
|
// expands to `mcp__<server>__*` — 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
|
/// Combined allow-list passed to `--allowedTools` (auto-approve) — covers
|
||||||
|
|
@ -605,20 +619,78 @@ pub fn builtin_tools_arg() -> String {
|
||||||
ALLOWED_BUILTIN_TOOLS.join(",")
|
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.<key>` block in the rendered claude config + a
|
||||||
|
/// `mcp__<key>__<tool>` 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<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
env: std::collections::BTreeMap<String, String>,
|
||||||
|
#[serde(default = "default_allowed_tools")]
|
||||||
|
#[serde(rename = "allowedTools")]
|
||||||
|
allowed_tools: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_allowed_tools() -> Vec<String> {
|
||||||
|
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<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>`.
|
||||||
/// `agent_binary` is the path (or PATH-resolvable name) of the `hive-ag3nt`
|
/// `agent_binary` is the path (or PATH-resolvable name) of the `hive-ag3nt`
|
||||||
/// executable; `socket` is the hyperhive per-agent socket bind-mounted into
|
/// executable; `socket` is the hyperhive per-agent socket bind-mounted into
|
||||||
/// the container (forwarded to the child as `--socket <path>`).
|
/// the container (forwarded to the child as `--socket <path>`). Merges in
|
||||||
|
/// any extra MCP servers declared via `hyperhive.extraMcpServers` in the
|
||||||
|
/// agent's NixOS config.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn render_claude_config(agent_binary: &str, socket: &std::path::Path) -> String {
|
pub fn render_claude_config(agent_binary: &str, socket: &std::path::Path) -> String {
|
||||||
let config = serde_json::json!({
|
let mut servers = serde_json::Map::new();
|
||||||
"mcpServers": {
|
servers.insert(
|
||||||
SERVER_NAME: {
|
SERVER_NAME.to_owned(),
|
||||||
"command": agent_binary,
|
serde_json::json!({
|
||||||
"args": ["--socket", socket.display().to_string(), "mcp"],
|
"command": agent_binary,
|
||||||
"env": {}
|
"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())
|
serde_json::to_string_pretty(&config).unwrap_or_else(|_| "{}".into())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,67 @@
|
||||||
{ pkgs, lib, ... }:
|
{ pkgs, lib, config, ... }:
|
||||||
{
|
{
|
||||||
# Shared scaffolding for any hyperhive harness container — both
|
# Shared scaffolding for any hyperhive harness container — both
|
||||||
# sub-agents (`agent-base.nix`) and the manager (`manager.nix`) extend
|
# sub-agents (`agent-base.nix`) and the manager (`manager.nix`) extend
|
||||||
# this. The systemd service that actually runs the harness binary
|
# this. The systemd service that actually runs the harness binary
|
||||||
# differs per role and lives in the child module.
|
# 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__<server-key>__` 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__<key>__<tool>`). 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;
|
boot.isNspawnContainer = true;
|
||||||
|
|
||||||
# `claude-code` is unfree. Each per-agent container's nixosConfiguration
|
# `claude-code` is unfree. Each per-agent container's nixosConfiguration
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue