//! Shared spec for `hyperhive.extraMcpServers` entries: the on-disk JSON //! shape the nix module (`nix/agent-modules/mcp.nix`) renders to //! `/etc/hyperhive/extra-mcp.json`, plus the parsing and per-entry //! JSON-rendering logic every consumer needs. Lives in `hive-agent-sock` //! rather than a crate of its own: this crate is already what's common //! across the in-container "agent plugins" (matrix/bash/subagent MCP //! daemons, `forge_notify`) — the todo-socket wire types above, and now //! this — so a new plugin-facing shared type belongs here rather than in //! a fresh single-purpose crate. use std::collections::BTreeMap; use std::path::Path; /// Where the NixOS module writes the per-agent extra-MCP spec. Each entry /// becomes an additional `mcpServers.` block in a rendered claude /// `--mcp-config`. pub const EXTRA_MCP_PATH: &str = "/etc/hyperhive/extra-mcp.json"; /// An extra MCP server declared via `hyperhive.extraMcpServers`. Two /// transports: `Stdio` (the consumer spawns `command` fresh each turn, talks /// JSON-RPC over its stdin/stdout) and `Http` (point claude at a long-lived /// streamable-http `url` instead — no per-turn spawn, no re-registration /// race). Internally tagged on the nix-rendered `type` field; unrecognised /// fields for the inactive variant (e.g. `command` on an `Http` entry) are /// ignored by serde's default struct deserialization. #[derive(Debug, Clone, serde::Deserialize)] #[serde(tag = "type", rename_all = "snake_case")] pub enum ExtraMcpServer { Stdio { command: String, #[serde(default)] args: Vec, #[serde(default)] env: BTreeMap, #[serde(default = "default_allowed_tools", rename = "allowedTools")] allowed_tools: Vec, #[serde(default, rename = "availableToSubagents")] available_to_subagents: bool, }, Http { url: String, #[serde(default = "default_allowed_tools", rename = "allowedTools")] allowed_tools: Vec, #[serde(default, rename = "availableToSubagents")] available_to_subagents: bool, }, } impl ExtraMcpServer { #[must_use] pub fn allowed_tools(&self) -> &[String] { match self { Self::Stdio { allowed_tools, .. } | Self::Http { allowed_tools, .. } => allowed_tools, } } /// Whether this entry is opted in to subagent sessions /// (`hyperhive.extraMcpServers..availableToSubagents`, default /// `false`). Subagents run `--strict-mcp-config` with no discovery, so /// this flag is the only path an entry reaches a subagent's own /// `--mcp-config` at all — see `hive-subagent-mcp`'s `mcp_config` /// module. #[must_use] pub fn available_to_subagents(&self) -> bool { match self { Self::Stdio { available_to_subagents, .. } | Self::Http { available_to_subagents, .. } => *available_to_subagents, } } /// Render this entry into the shape claude expects under /// `mcpServers.` in a `--mcp-config` blob. `state_dir` is injected /// as `HYPERHIVE_STATE_DIR` into a stdio entry's env when the entry /// doesn't already set it, so an extra stdio MCP server can resolve the /// agent's durable state dir without its author hard-coding it; an http /// entry has no child-process env to inject into (the daemon behind the /// URL resolves its own state dir independently). #[must_use] pub fn to_json_entry(&self, state_dir: &Path) -> serde_json::Value { match self { Self::Stdio { command, args, env, .. } => { let mut env = env.clone(); env.entry("HYPERHIVE_STATE_DIR".to_owned()) .or_insert_with(|| state_dir.display().to_string()); serde_json::json!({ "command": command, "args": args, "env": env }) } Self::Http { url, .. } => serde_json::json!({ "type": "http", "url": url }), } } } fn default_allowed_tools() -> Vec { vec!["*".to_owned()] } /// Read + parse the extra-MCP spec from [`EXTRA_MCP_PATH`]. Returns an empty /// map when the file is missing or unparsable (the agent has none /// configured, or the file is malformed — both cases degrade to "no extra /// servers"). #[must_use] pub fn load_extra_mcp() -> BTreeMap { let Ok(raw) = std::fs::read_to_string(EXTRA_MCP_PATH) else { return 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", ); BTreeMap::new() }) } #[cfg(test)] mod tests { use super::*; #[test] fn available_to_subagents_defaults_false() { let stdio: ExtraMcpServer = serde_json::from_value(serde_json::json!({"type": "stdio", "command": "/bin/foo"})) .unwrap(); assert!(!stdio.available_to_subagents()); let http: ExtraMcpServer = serde_json::from_value( serde_json::json!({"type": "http", "url": "http://127.0.0.1:1/mcp"}), ) .unwrap(); assert!(!http.available_to_subagents()); } #[test] fn available_to_subagents_true_is_read() { let stdio: ExtraMcpServer = serde_json::from_value(serde_json::json!({ "type": "stdio", "command": "/bin/foo", "availableToSubagents": true, })) .unwrap(); assert!(stdio.available_to_subagents()); } #[test] fn to_json_entry_injects_state_dir_when_absent() { let stdio: ExtraMcpServer = serde_json::from_value(serde_json::json!({ "type": "stdio", "command": "/bin/foo", })) .unwrap(); let entry = stdio.to_json_entry(Path::new("/agents/x/state")); assert_eq!(entry["env"]["HYPERHIVE_STATE_DIR"], "/agents/x/state"); } #[test] fn to_json_entry_respects_explicit_state_dir() { let stdio: ExtraMcpServer = serde_json::from_value(serde_json::json!({ "type": "stdio", "command": "/bin/foo", "env": {"HYPERHIVE_STATE_DIR": "/custom"}, })) .unwrap(); let entry = stdio.to_json_entry(Path::new("/agents/x/state")); assert_eq!(entry["env"]["HYPERHIVE_STATE_DIR"], "/custom"); } #[test] fn http_entry_has_no_env() { let http: ExtraMcpServer = serde_json::from_value(serde_json::json!({ "type": "http", "url": "http://127.0.0.1:1/mcp", })) .unwrap(); let entry = http.to_json_entry(Path::new("/agents/x/state")); assert_eq!(entry["url"], "http://127.0.0.1:1/mcp"); assert!(entry.get("env").is_none()); } }