feat(#2659): serve hive-bash-mcp over persistent streamable-http, drop stdio bridge
This commit is contained in:
parent
ecc2ebe682
commit
c4fcf7fbf1
26 changed files with 376 additions and 574 deletions
|
|
@ -191,7 +191,7 @@ pub fn allowed_mcp_tools(groups: &[hive_sh4re::ToolGroup]) -> Vec<String> {
|
|||
if server == SERVER_NAME || !extra_server_enabled(&server, groups) {
|
||||
continue;
|
||||
}
|
||||
for pat in spec.allowed_tools {
|
||||
for pat in spec.allowed_tools() {
|
||||
out.push(format!("mcp__{server}__{pat}"));
|
||||
}
|
||||
}
|
||||
|
|
@ -250,16 +250,41 @@ pub fn builtin_tools_arg() -> String {
|
|||
/// `mcp__<key>__<tool>` pattern in `--allowedTools`.
|
||||
const EXTRA_MCP_PATH: &str = "/etc/hyperhive/extra-mcp.json";
|
||||
|
||||
/// An extra MCP server declared via `hyperhive.extraMcpServers`. Two
|
||||
/// transports: `Stdio` (claude spawns `command` fresh each turn, talks
|
||||
/// JSON-RPC over its stdin/stdout) and `Http` (claude points at a
|
||||
/// long-lived streamable-http `url` instead — no per-turn spawn, no
|
||||
/// re-registration race, same shape as the built-in hyperhive surface).
|
||||
/// 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, 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>,
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
enum ExtraMcpServer {
|
||||
Stdio {
|
||||
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>,
|
||||
},
|
||||
Http {
|
||||
url: String,
|
||||
#[serde(default = "default_allowed_tools")]
|
||||
#[serde(rename = "allowedTools")]
|
||||
allowed_tools: Vec<String>,
|
||||
},
|
||||
}
|
||||
|
||||
impl ExtraMcpServer {
|
||||
fn allowed_tools(&self) -> &[String] {
|
||||
match self {
|
||||
Self::Stdio { allowed_tools, .. } | Self::Http { allowed_tools, .. } => allowed_tools,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn default_allowed_tools() -> Vec<String> {
|
||||
|
|
@ -287,7 +312,9 @@ fn load_extra_mcp() -> std::collections::BTreeMap<String, ExtraMcpServer> {
|
|||
/// 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 `hyperhive.extraMcpServers` (those stay stdio bridges).
|
||||
/// declared via `hyperhive.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() });
|
||||
|
|
@ -296,7 +323,7 @@ pub fn render_claude_config() -> String {
|
|||
|
||||
/// 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 tool-group-permitted extra stdio servers). The harness compares
|
||||
/// any tool-group-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).
|
||||
|
|
@ -307,8 +334,9 @@ pub fn configured_server_names() -> Vec<String> {
|
|||
|
||||
/// Build the `mcpServers` map claude gets in its `--mcp-config`: the
|
||||
/// built-in hyperhive HTTP surface plus any tool-group-permitted extra
|
||||
/// stdio servers. Shared by [`render_claude_config`] (serialises it) and
|
||||
/// [`configured_server_names`] (lists its keys) so the two never drift.
|
||||
/// 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
|
||||
|
|
@ -316,10 +344,9 @@ fn build_mcp_servers() -> serde_json::Map<String, serde_json::Value> {
|
|||
// 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. Extra servers (matrix/bash) stay stdio
|
||||
// bridges. The port comes from `HYPERHIVE_MCP_HTTP_PORT` (always set by
|
||||
// the harness); `DEFAULT_MCP_HTTP_PORT` is the fallback matching the nix
|
||||
// default.
|
||||
// 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())
|
||||
|
|
@ -329,16 +356,18 @@ fn build_mcp_servers() -> serde_json::Map<String, serde_json::Value> {
|
|||
"url": format!("http://127.0.0.1:{port}/mcp"),
|
||||
});
|
||||
servers.insert(SERVER_NAME.to_owned(), hyperhive_entry);
|
||||
// Auto-inject HYPERHIVE_STATE_DIR so extra MCP servers can resolve the
|
||||
// agent's durable state dir without the agent author hard-coding it.
|
||||
// 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 tool-group-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 groups = effective_tool_groups();
|
||||
for (name, mut spec) in load_extra_mcp() {
|
||||
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",
|
||||
|
|
@ -352,17 +381,20 @@ fn build_mcp_servers() -> serde_json::Map<String, serde_json::Value> {
|
|||
);
|
||||
continue;
|
||||
}
|
||||
spec.env
|
||||
.entry("HYPERHIVE_STATE_DIR".to_owned())
|
||||
.or_insert_with(|| state_dir.display().to_string());
|
||||
servers.insert(
|
||||
name,
|
||||
serde_json::json!({
|
||||
"command": spec.command,
|
||||
"args": spec.args,
|
||||
"env": spec.env,
|
||||
}),
|
||||
);
|
||||
let entry = match spec {
|
||||
ExtraMcpServer::Stdio {
|
||||
command,
|
||||
args,
|
||||
mut env,
|
||||
..
|
||||
} => {
|
||||
env.entry("HYPERHIVE_STATE_DIR".to_owned())
|
||||
.or_insert_with(|| state_dir.display().to_string());
|
||||
serde_json::json!({ "command": command, "args": args, "env": env })
|
||||
}
|
||||
ExtraMcpServer::Http { url, .. } => serde_json::json!({ "type": "http", "url": url }),
|
||||
};
|
||||
servers.insert(name, entry);
|
||||
}
|
||||
servers
|
||||
}
|
||||
|
|
|
|||
|
|
@ -121,7 +121,7 @@ pub struct Snapshot {
|
|||
pub tool_breakdown: Vec<KeyCount>,
|
||||
/// Top shell commands ("favorite tools") by invocation count across
|
||||
/// the window, capped to 10. Normalised command heads recorded per
|
||||
/// bash task into the `bash_commands` table by hive-bash-mcp. Empty
|
||||
/// bash task into the `bash_commands` table by hive-bash-daemon. Empty
|
||||
/// until that capture lands (or on any agent that hasn't run a bash
|
||||
/// task) — the table is created lazily by the writer, so a read
|
||||
/// before the first insert returns an empty list, not an error.
|
||||
|
|
@ -420,7 +420,7 @@ fn read_session_count(conn: &Connection, from: i64) -> rusqlite::Result<u64> {
|
|||
|
||||
/// Aggregate the top shell-command heads ("favorite tools") over
|
||||
/// `[from, now]` from the `bash_commands` table — one row per bash task
|
||||
/// (`ts INTEGER NOT NULL, head TEXT NOT NULL`), written by hive-bash-mcp.
|
||||
/// (`ts INTEGER NOT NULL, head TEXT NOT NULL`), written by hive-bash-daemon.
|
||||
///
|
||||
/// Returns `Err` (which the caller maps to an empty list) when the
|
||||
/// table doesn't exist yet — the writer creates it lazily on first
|
||||
|
|
|
|||
Loading…
Reference in a new issue