From 53eed2a757242173719fee45217a0d24c994929e Mon Sep 17 00:00:00 2001 From: damocles Date: Tue, 20 Oct 2026 12:58:25 +0200 Subject: [PATCH] nix: extraMcpServers http entries can read request headers from one file via headersHelper --- docs/turn-loop/mcp.md | 5 +++ hive-agent-sock/src/extra_mcp.rs | 54 +++++++++++++++++++++++++++++++- nix/agent-modules/mcp.nix | 21 +++++++++++++ 3 files changed, 79 insertions(+), 1 deletion(-) diff --git a/docs/turn-loop/mcp.md b/docs/turn-loop/mcp.md index 37dcc503..f0bbd9e8 100644 --- a/docs/turn-loop/mcp.md +++ b/docs/turn-loop/mcp.md @@ -21,6 +21,11 @@ the tools land in claude as toggle (default `false`) — see [`docs/tools/subagent.md`](../tools/subagent.md#mcp-servers-available-to-a-subagent) for what reaches a subagent's own `--mcp-config` and what doesn't. +An `"http"` entry that needs request headers (a bearer-authenticated remote +server) sets `headersFile` to an absolute path: a file holding a JSON object +of header name to value. It renders as claude's `headersHelper` (`cat` of the +file), so the secret never enters the nix store, and claude re-reads a rotated token +on its next reconnect. ACP agents don't read it. The agent's permissions gate tool access ([`docs/process/conventions.md#permissions`](../process/conventions.md)). diff --git a/hive-agent-sock/src/extra_mcp.rs b/hive-agent-sock/src/extra_mcp.rs index a9db756b..3bf3fbe1 100644 --- a/hive-agent-sock/src/extra_mcp.rs +++ b/hive-agent-sock/src/extra_mcp.rs @@ -39,6 +39,12 @@ pub enum ExtraMcpServer { }, Http { url: String, + /// Absolute path to a file holding a JSON object of request headers + /// (`{"Authorization": "Bearer ..."}`). Rendered as claude's + /// `headersHelper` so the secret stays in the file, never in the nix + /// store or this config. + #[serde(default, rename = "headersFile")] + headers_file: Option, #[serde(default = "default_allowed_tools", rename = "allowedTools")] allowed_tools: Vec, #[serde(default, rename = "availableToSubagents")] @@ -92,11 +98,30 @@ impl ExtraMcpServer { .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 }), + Self::Http { + url, headers_file, .. + } => { + let mut entry = serde_json::json!({ "type": "http", "url": url }); + if let Some(path) = headers_file { + // claude runs this in a shell at session start, on + // reconnect and after a 401/403, and reads a JSON object + // of headers from its stdout - so `cat` of the file is the + // whole helper, and a rotated token is picked up on the + // next reconnect. + entry["headersHelper"] = + serde_json::Value::String(format!("cat {}", shell_single_quote(path))); + } + entry + } } } } +/// `s` as one single-quoted shell word. +fn shell_single_quote(s: &str) -> String { + format!("'{}'", s.replace('\'', "'\\''")) +} + fn default_allowed_tools() -> Vec { vec!["*".to_owned()] } @@ -183,4 +208,31 @@ mod tests { assert_eq!(entry["url"], "http://127.0.0.1:1/mcp"); assert!(entry.get("env").is_none()); } + + #[test] + fn http_headers_file_renders_a_headers_helper() { + let http: ExtraMcpServer = serde_json::from_value(serde_json::json!({ + "type": "http", + "url": "https://x.example/mcp", + "headersFile": "/run/secrets/it's a header file", + })) + .unwrap(); + let entry = http.to_json_entry(Path::new("/agents/x/state")); + assert_eq!(entry["url"], "https://x.example/mcp"); + assert_eq!( + entry["headersHelper"], + "cat '/run/secrets/it'\\''s a header file'" + ); + } + + #[test] + fn http_without_headers_file_has_no_helper() { + 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!(entry.get("headersHelper").is_none()); + } } diff --git a/nix/agent-modules/mcp.nix b/nix/agent-modules/mcp.nix index 82e32433..0db76d7f 100644 --- a/nix/agent-modules/mcp.nix +++ b/nix/agent-modules/mcp.nix @@ -93,6 +93,23 @@ in `type = "http"`; ignored (leave `null`) for `"stdio"`. ''; }; + headersFile = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + example = "/agents/janet/state/secrets/truth-headers.json"; + description = '' + Absolute path (a string, never a nix path literal, which would + copy the secret into the world-readable store) to a file holding + a JSON object of request headers, e.g. + `{"Authorization": "Bearer "}`, for an `"http"` server + that needs them. Rendered as claude's `headersHelper` + (`cat `), so the file is read when claude connects, on + reconnect and after a 401/403: a rotated token needs no + harness restart. A missing or non-JSON file leaves that server + without headers (claude's own behaviour). `"http"` only; not + applied to ACP agents. + ''; + }; allowedTools = lib.mkOption { type = lib.types.listOf lib.types.str; default = [ "*" ]; @@ -250,6 +267,10 @@ in assertion = spec.type != "stdio" || spec.command != null; message = "services.hyperhive.agent.extraMcpServers.${name}: type = \"stdio\" requires `command` to be set"; }) config.services.hyperhive.agent.extraMcpServers + ++ lib.mapAttrsToList (name: spec: { + assertion = spec.headersFile == null || (spec.type == "http" && lib.hasPrefix "/" spec.headersFile); + message = "services.hyperhive.agent.extraMcpServers.${name}: headersFile needs type = \"http\" and an absolute path string"; + }) config.services.hyperhive.agent.extraMcpServers ++ lib.mapAttrsToList (name: spec: { assertion = spec.type != "http" || spec.url != null; message = "services.hyperhive.agent.extraMcpServers.${name}: type = \"http\" requires `url` to be set";