186 lines
6.8 KiB
Rust
186 lines
6.8 KiB
Rust
//! 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.<key>` 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<String>,
|
|
#[serde(default)]
|
|
env: BTreeMap<String, String>,
|
|
#[serde(default = "default_allowed_tools", rename = "allowedTools")]
|
|
allowed_tools: Vec<String>,
|
|
#[serde(default, rename = "availableToSubagents")]
|
|
available_to_subagents: bool,
|
|
},
|
|
Http {
|
|
url: String,
|
|
#[serde(default = "default_allowed_tools", rename = "allowedTools")]
|
|
allowed_tools: Vec<String>,
|
|
#[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.<name>.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.<name>` 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<String> {
|
|
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<String, ExtraMcpServer> {
|
|
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());
|
|
}
|
|
}
|