Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/hive-agent/src/mcp_config.rs

266 lines
12 KiB
Rust

//! Claude launch-config layer: resolves the agent's permission
//! set into the `--allowedTools` / `--tools` argument strings and renders the
//! `--mcp-config` blob claude reads at spawn (built-in hyperhive server +
//! any `services.hyperhive.agent.extraMcpServers`). Pure config-string generation consumed by
//! [`crate::turn`] when it builds the claude command. It never touches the
//! running MCP server (a separate binary) — the `send` allow-list check that
//! server enforces lives alongside it in the `hive-agent-mcp` crate.
use hive_types::Permission;
/// Name of the hyperhive MCP server inside claude's view. Claude prefixes
/// tools as `mcp__<this>__<tool>` (e.g. `mcp__hyperhive__send`).
pub const SERVER_NAME: &str = "hyperhive";
/// Default loopback port the built-in hyperhive MCP surface is served on
/// (streamable HTTP, via the persistent `hive-mcp-http` daemon). Overridable
/// via `services.hyperhive.agent.mcp.httpPort`; **must match that option's default** in
/// `nix/agent-modules/mcp.nix`. Safe as a single fixed value across all
/// agents because each container runs in its own private network namespace,
/// so `127.0.0.1:<port>` is per-container-private (no cross-agent collision).
pub const DEFAULT_MCP_HTTP_PORT: u16 = 8790;
/// The value for claude's `--tools` flag this turn: which built-in tools exist
/// in the session at all, from the agent's current permissions. The
/// resolution lives in `hive_sh4re::permissions` because the subagent daemon
/// (`hive-subagent-mcp`) spawns its own `claude` from the same one.
#[must_use]
pub fn builtin_tools_arg() -> String {
hive_sh4re::permissions::builtin_tools_for(&crate::swarm_permissions::current()).join(",")
}
/// Permission an extra (out-of-process) MCP server is gated behind, if any.
///
/// Most `services.hyperhive.agent.extraMcpServers` entries are ungated — available whenever
/// the operator declares them. The `bash` server is the exception: raw shell
/// execution is a privilege, so it is only exposed when the agent holds
/// `Execution`. Unlike the in-process hyperhive tools (gated at dispatch),
/// an out-of-process server has **no** later enforcement point — once it is
/// in the claude MCP config the agent can call it. So this gate, applied at
/// config-render time, is the security boundary for those servers.
fn extra_server_required_permission(server: &str) -> Option<Permission> {
match server {
"bash" => Some(Permission::Execution),
_ => None,
}
}
/// Whether an extra MCP server should be exposed to claude given the agent's
/// `perms`. A gated server (see [`extra_server_required_permission`]) is
/// suppressed when the agent lacks its required permission.
fn extra_server_enabled(server: &str, perms: &[Permission]) -> bool {
extra_server_required_permission(server).is_none_or(|required| perms.contains(&required))
}
#[cfg(test)]
mod extra_server_gate_tests {
use super::{extra_server_enabled, extra_server_required_permission};
use hive_types::Permission;
#[test]
fn bash_is_gated_behind_execution() {
assert_eq!(
extra_server_required_permission("bash"),
Some(Permission::Execution)
);
// Suppressed without Execution, even if other groups are present.
assert!(!extra_server_enabled(
"bash",
&[Permission::Messaging, Permission::Inbox]
));
// Available once Execution is granted.
assert!(extra_server_enabled("bash", &[Permission::Execution]));
}
#[test]
fn other_servers_are_ungated() {
assert_eq!(extra_server_required_permission("matrix"), None);
assert_eq!(extra_server_required_permission("scraper"), None);
// An ungated server is available regardless of (even empty) groups.
assert!(extra_server_enabled("matrix", &[]));
assert!(extra_server_enabled("scraper", &[Permission::Messaging]));
}
}
/// MCP tools claude is allowed to call without prompting, derived from
/// the supplied permissions. Adding a new `#[tool]` fn to a server impl
/// requires updating the matching `Permission::tools()` slice in hive-types
/// (single source of truth).
#[must_use]
pub fn allowed_mcp_tools(perms: &[Permission]) -> Vec<String> {
// Collect all tool names, deduplicating while preserving order.
// Always-on tools (e.g. `set_status`) come first so they're present
// regardless of which permissions the agent holds — a misconfigured
// agent still has to be able to report its dashboard status.
let mut seen = std::collections::HashSet::new();
let mut out: Vec<String> = Permission::ALWAYS_ON_TOOLS
.iter()
.copied()
.chain(perms.iter().flat_map(|p| p.tools().iter().copied()))
.filter(|t| seen.insert(*t))
.map(|t| format!("mcp__{SERVER_NAME}__{t}"))
.collect();
// Extra MCP servers declared via `services.hyperhive.agent.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 hive_agent_sock::extra_mcp::load_extra_mcp() {
if server == SERVER_NAME || !extra_server_enabled(&server, perms) {
continue;
}
for pat in spec.allowed_tools() {
out.push(format!("mcp__{server}__{pat}"));
}
}
out
}
/// Combined allow-list passed to `--allowedTools` (auto-approve) — covers
/// both the built-ins and the MCP surface.
#[must_use]
pub fn allowed_tools_arg() -> String {
let perms = crate::swarm_permissions::current();
// The same built-ins `--tools` makes exist this session, so a tool that
// exists is never one claude has to prompt about.
let mut all: Vec<String> = hive_sh4re::permissions::builtin_tools_for(&perms)
.into_iter()
.map(ToOwned::to_owned)
.collect();
all.extend(allowed_mcp_tools(&perms));
all.join(",")
}
/// Render the MCP config blob claude reads from `--mcp-config <path>`.
/// The built-in `hyperhive` surface is an HTTP entry pointing at the
/// persistent `hive-mcp-http` daemon (see [`DEFAULT_MCP_HTTP_PORT`]); there
/// is no per-turn stdio child for it. Merges in any extra MCP servers
/// declared via `services.hyperhive.agent.extraMcpServers` — each one is either a
/// per-turn stdio bridge or another persistent HTTP entry, per its own
/// `type`.
#[must_use]
pub fn render_claude_config() -> String {
let config = serde_json::json!({ "mcpServers": build_mcp_servers() });
serde_json::to_string_pretty(&config).unwrap_or_else(|_| "{}".into())
}
/// The set of MCP server names claude is configured with this turn — the
/// keys of the rendered `--mcp-config` (built-in hyperhive HTTP surface +
/// any permitted extra servers). The harness compares
/// this against the per-turn `system`/`init` event's `mcp_servers` to
/// detect a configured server that failed to connect or was dropped
/// (the MCP-health instrumentation).
#[must_use]
pub fn configured_server_names() -> Vec<String> {
build_mcp_servers().into_iter().map(|(k, _)| k).collect()
}
/// Build the `mcpServers` map claude gets in its `--mcp-config`: the
/// built-in hyperhive HTTP surface plus any permitted extra
/// servers (stdio or http, per each entry's `type`). Shared by
/// [`render_claude_config`] (serialises it) and [`configured_server_names`]
/// (lists its keys) so the two never drift.
fn build_mcp_servers() -> serde_json::Map<String, serde_json::Value> {
let mut servers = serde_json::Map::new();
// The built-in hyperhive surface is served exclusively over streamable
// HTTP by the persistent `hive-mcp-http` daemon (loopback, inside the
// agent's private network namespace). Point claude at the stable URL
// rather than respawning a fresh stdio child each turn: the URL survives
// the per-turn claude re-spawn, so there is no per-turn re-registration
// race for the hyperhive surface. The port comes from
// `HYPERHIVE_MCP_HTTP_PORT` (always set by the harness);
// `DEFAULT_MCP_HTTP_PORT` is the fallback matching the nix default.
let port = std::env::var("HYPERHIVE_MCP_HTTP_PORT")
.ok()
.and_then(|p| p.trim().parse::<u16>().ok())
.unwrap_or(DEFAULT_MCP_HTTP_PORT);
let hyperhive_entry = serde_json::json!({
"type": "http",
"url": format!("http://127.0.0.1:{port}/mcp"),
});
servers.insert(SERVER_NAME.to_owned(), hyperhive_entry);
// Auto-inject HYPERHIVE_STATE_DIR so extra stdio MCP servers can resolve
// the agent's durable state dir without the agent author hard-coding it.
// User-supplied env takes precedence — we only fill in the missing key.
// (Http entries have no child-process env to inject into — the daemon
// behind the URL resolves its own state dir independently.)
let state_dir = crate::paths::state_dir();
// Gate permission-restricted extra servers (e.g. `bash` → `Execution`).
// This is the security boundary for them: an out-of-process server the
// agent isn't entitled to must not even appear in the MCP config, or the
// agent could call it directly (there is no later enforcement point).
let perms = crate::swarm_permissions::current();
for (name, spec) in hive_agent_sock::extra_mcp::load_extra_mcp() {
if name == SERVER_NAME {
tracing::warn!(
"extra MCP server name `{SERVER_NAME}` collides with the built-in surface; ignoring",
);
continue;
}
if !extra_server_enabled(&name, &perms) {
tracing::info!(
server = %name,
"extra MCP server suppressed: agent lacks the required permission"
);
continue;
}
servers.insert(name, spec.to_json_entry(&state_dir));
}
servers
}
#[cfg(test)]
mod tests {
use super::{SERVER_NAME, allowed_mcp_tools};
use hive_types::Permission;
fn qualified(tool: &str) -> String {
format!("mcp__{SERVER_NAME}__{tool}")
}
#[test]
fn set_status_present_with_no_groups() {
// An agent with no permissions (or any set that omits
// `meta`) must still be able to report its dashboard status.
let tools = allowed_mcp_tools(&[]);
assert!(
tools.contains(&qualified("set_status")),
"set_status missing from empty-group allow-list: {tools:?}"
);
}
#[test]
fn mark_todos_done_present_with_no_groups() {
// Regression: mark_todos_done was declared as a
// tool but never added to any group, so no agent could ever
// get it into --allowedTools regardless of which groups it held —
// including `inbox`, which only gates get_loose_ends/
// cancel_loose_end/remind.
let tools = allowed_mcp_tools(&[]);
assert!(
tools.contains(&qualified("mark_todos_done")),
"mark_todos_done missing from empty-group allow-list: {tools:?}"
);
let tools = allowed_mcp_tools(&[Permission::Inbox]);
assert!(tools.contains(&qualified("mark_todos_done")));
}
#[test]
fn set_status_present_without_meta_group() {
let tools = allowed_mcp_tools(&[Permission::Messaging, Permission::Inbox]);
assert!(tools.contains(&qualified("set_status")));
// get_agent_meta stays gated behind `meta` — only set_status is always-on.
assert!(!tools.contains(&qualified("get_agent_meta")));
}
#[test]
fn no_duplicate_set_status_when_meta_granted() {
let tools = allowed_mcp_tools(&[Permission::Meta]);
let count = tools
.iter()
.filter(|t| **t == qualified("set_status"))
.count();
assert_eq!(count, 1, "set_status duplicated: {tools:?}");
assert!(tools.contains(&qualified("get_agent_meta")));
}
}